Compare commits
69 Commits
maf
...
9e14849014
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e14849014 | ||
|
|
e2c0ab5389 | ||
|
|
a06a75ee97 | ||
|
|
937e8db776 | ||
|
|
64aa24c07b | ||
|
|
b2e903e968 | ||
|
|
c2a27abcac | ||
|
|
a2bfff5790 | ||
|
|
66cfd71ef5 | ||
|
|
aa0bdecc50 | ||
|
|
0708f833c8 | ||
|
|
0ffeb41605 | ||
|
|
44d9fbb0f6 | ||
|
|
32efbb0324 | ||
|
|
1711ee410d | ||
|
|
b0d632dcb7 | ||
|
|
435efbcb90 | ||
|
|
9611ff2088 | ||
|
|
4e2bf0da6c | ||
|
|
298ce03aa6 | ||
|
|
5d6797b16a | ||
|
|
2ffad479ba | ||
|
|
9b25c62662 | ||
|
|
1b3d01c78c | ||
|
|
0ef4b52fcc | ||
|
|
2043976998 | ||
|
|
3cbc868e9b | ||
|
|
c74ce24727 | ||
|
|
14bbd62262 | ||
|
|
0c95b6aa6e | ||
|
|
f77cc57cab | ||
|
|
59b81148ac | ||
|
|
04e4946f3d | ||
|
|
319f8f7d7b | ||
|
|
9069e3dbcf | ||
|
|
71a8707241 | ||
|
|
0e140548b7 | ||
|
|
12b8e4bd0e | ||
|
|
f4f1600782 | ||
|
|
caf6f3fe60 | ||
|
|
df3a06051b | ||
|
|
c80e5daf7d | ||
|
|
ea11d72c00 | ||
|
|
9e03187a95 | ||
|
|
667358fa0b | ||
|
|
69298c2ffa | ||
|
|
82e5c79868 | ||
|
|
65f7316c82 | ||
|
|
12cf1b6323 | ||
|
|
4444f2b808 | ||
|
|
0ccfa57d7b | ||
| f2f741411b | |||
|
|
6e7a237df9 | ||
|
|
6a45f3d67d | ||
|
|
c5c3b56200 | ||
| 556fc5af20 | |||
|
|
03115a04ec | ||
|
|
de4e692bce | ||
|
|
d324d204d5 | ||
|
|
b4cb0a200e | ||
| 02fcf07235 | |||
| e11c1e4fc4 | |||
|
|
83dfa66d85 | ||
|
|
c07c9dd07d | ||
|
|
a41c2b79af | ||
|
|
5cac4d6dde | ||
|
|
a8da1c6a70 | ||
|
|
8c7dbd24ad | ||
|
|
694925e326 |
@@ -13,13 +13,64 @@ jobs:
|
|||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# ✅ 使用 Gitea 兼容的代码检出方式
|
# 网络连接测试
|
||||||
|
- 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
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: https://gitea.com/actions/checkout@v3
|
uses: https://gitea.com/actions/checkout@v3
|
||||||
with:
|
# 添加重试策略
|
||||||
gitea-server: http://192.168.31.14:14200
|
continue-on-error: true
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
ref: ${{ gitea.ref }} # 必须传递 Gitea 的 ref 参数
|
# 手动重试逻辑
|
||||||
|
- 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
|
||||||
|
|
||||||
- name: Cleanup old containers
|
- name: Cleanup old containers
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -404,3 +404,4 @@ FodyWeavers.xsd
|
|||||||
Web/dist
|
Web/dist
|
||||||
# ESLint
|
# ESLint
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
.aider*
|
||||||
|
|||||||
3
Common/GlobalUsings.cs
Normal file
3
Common/GlobalUsings.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
global using System.Reflection;
|
||||||
|
global using System.Text.Json;
|
||||||
|
global using Microsoft.Extensions.DependencyInjection;
|
||||||
11
Common/IDateTimeProvider.cs
Normal file
11
Common/IDateTimeProvider.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Common;
|
||||||
|
|
||||||
|
public interface IDateTimeProvider
|
||||||
|
{
|
||||||
|
DateTime Now { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DateTimeProvider : IDateTimeProvider
|
||||||
|
{
|
||||||
|
public DateTime Now => DateTime.Now;
|
||||||
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
using System.Reflection;
|
namespace Common;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace Common;
|
|
||||||
|
|
||||||
public static class TypeExtensions
|
public static class TypeExtensions
|
||||||
{
|
{
|
||||||
@@ -10,8 +7,8 @@ public static class TypeExtensions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static T? DeepClone<T>(this T source)
|
public static T? DeepClone<T>(this T source)
|
||||||
{
|
{
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(source);
|
var json = JsonSerializer.Serialize(source);
|
||||||
return System.Text.Json.JsonSerializer.Deserialize<T>(json);
|
return JsonSerializer.Deserialize<T>(json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +22,7 @@ public static class ServiceExtension
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddServices(this IServiceCollection services)
|
public static IServiceCollection AddServices(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
|
||||||
// 扫描程序集
|
// 扫描程序集
|
||||||
var serviceAssembly = Assembly.Load("Service");
|
var serviceAssembly = Assembly.Load("Service");
|
||||||
var repositoryAssembly = Assembly.Load("Repository");
|
var repositoryAssembly = Assembly.Load("Repository");
|
||||||
@@ -41,7 +39,7 @@ public static class ServiceExtension
|
|||||||
private static void RegisterServices(IServiceCollection services, Assembly assembly)
|
private static void RegisterServices(IServiceCollection services, Assembly assembly)
|
||||||
{
|
{
|
||||||
var types = assembly.GetTypes()
|
var types = assembly.GetTypes()
|
||||||
.Where(t => t.IsClass && !t.IsAbstract);
|
.Where(t => t is { IsClass: true, IsAbstract: false });
|
||||||
|
|
||||||
foreach (var type in types)
|
foreach (var type in types)
|
||||||
{
|
{
|
||||||
@@ -71,14 +69,13 @@ public static class ServiceExtension
|
|||||||
private static void RegisterRepositories(IServiceCollection services, Assembly assembly)
|
private static void RegisterRepositories(IServiceCollection services, Assembly assembly)
|
||||||
{
|
{
|
||||||
var types = assembly.GetTypes()
|
var types = assembly.GetTypes()
|
||||||
.Where(t => t.IsClass && !t.IsAbstract);
|
.Where(t => t is { IsClass: true, IsAbstract: false });
|
||||||
|
|
||||||
foreach (var type in types)
|
foreach (var type in types)
|
||||||
{
|
{
|
||||||
var interfaces = type.GetInterfaces()
|
var interfaces = type.GetInterfaces()
|
||||||
.Where(i => i.Name.StartsWith("I")
|
.Where(i => i.Name.StartsWith("I")
|
||||||
&& i.Namespace == "Repository"
|
&& i is { Namespace: "Repository", IsGenericType: false }); // 排除泛型接口如 IBaseRepository<T>
|
||||||
&& !i.IsGenericType); // 排除泛型接口如 IBaseRepository<T>
|
|
||||||
|
|
||||||
foreach (var @interface in interfaces)
|
foreach (var @interface in interfaces)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Email & MIME Libraries -->
|
<!-- Email & MIME Libraries -->
|
||||||
<PackageVersion Include="FreeSql" Version="3.5.304" />
|
<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="MailKit" Version="4.14.1" />
|
<PackageVersion Include="MailKit" Version="4.14.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||||
<PackageVersion Include="MimeKit" Version="4.14.0" />
|
<PackageVersion Include="MimeKit" Version="4.14.0" />
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
|
||||||
<!-- Database -->
|
<!-- Database -->
|
||||||
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.304" />
|
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.305" />
|
||||||
<PackageVersion Include="WebPush" Version="1.0.12" />
|
<PackageVersion Include="WebPush" Version="1.0.12" />
|
||||||
<PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" />
|
<PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" />
|
||||||
<!-- File Processing -->
|
<!-- File Processing -->
|
||||||
@@ -33,5 +35,12 @@
|
|||||||
<!-- Text Processing -->
|
<!-- Text Processing -->
|
||||||
<PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" />
|
<PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" />
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
<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" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 多阶段构建 Dockerfile
|
# 多阶段构建 Dockerfile
|
||||||
# 第一阶段:构建前端
|
# 第一阶段:构建前端
|
||||||
FROM node:20-alpine AS frontend-build
|
FROM node:20-slim AS frontend-build
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ COPY Entity/*.csproj ./Entity/
|
|||||||
COPY Repository/*.csproj ./Repository/
|
COPY Repository/*.csproj ./Repository/
|
||||||
COPY Service/*.csproj ./Service/
|
COPY Service/*.csproj ./Service/
|
||||||
COPY WebApi/*.csproj ./WebApi/
|
COPY WebApi/*.csproj ./WebApi/
|
||||||
|
COPY WebApi.Test/*.csproj ./WebApi.Test/
|
||||||
|
|
||||||
# 还原依赖
|
# 还原依赖
|
||||||
RUN dotnet restore
|
RUN dotnet restore
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csp
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entity", "Entity\Entity.csproj", "{B1BCD944-C4F5-406E-AE66-864E4BA21522}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entity", "Entity\Entity.csproj", "{B1BCD944-C4F5-406E-AE66-864E4BA21522}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi.Test", "WebApi.Test\WebApi.Test.csproj", "{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -83,6 +85,18 @@ Global
|
|||||||
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x64.Build.0 = Release|Any CPU
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
<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">
|
<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/=fsql/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
<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>
|
||||||
@@ -2,31 +2,6 @@
|
|||||||
|
|
||||||
public class BudgetArchive : BaseEntity
|
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>
|
||||||
/// 归档目标年份
|
/// 归档目标年份
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -37,8 +12,79 @@ public class BudgetArchive : BaseEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int Month { get; set; }
|
public int Month { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 归档内容
|
||||||
|
/// </summary>
|
||||||
|
[JsonMap]
|
||||||
|
public BudgetArchiveContent[] Content { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 归档日期
|
/// 归档日期
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime ArchiveDate { get; set; } = DateTime.Now;
|
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,6 +34,16 @@ public class BudgetRecord : BaseEntity
|
|||||||
/// 开始日期
|
/// 开始日期
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime StartDate { get; set; } = DateTime.Now;
|
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
|
public enum BudgetPeriodType
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.Security.Cryptography;
|
namespace Entity;
|
||||||
|
|
||||||
namespace Entity;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 邮件消息实体
|
/// 邮件消息实体
|
||||||
@@ -39,7 +37,7 @@ public class EmailMessage : BaseEntity
|
|||||||
public string ComputeBodyHash()
|
public string ComputeBodyHash()
|
||||||
{
|
{
|
||||||
using var md5 = MD5.Create();
|
using var md5 = MD5.Create();
|
||||||
var inputBytes = System.Text.Encoding.UTF8.GetBytes(Body + HtmlBody);
|
var inputBytes = Encoding.UTF8.GetBytes(Body + HtmlBody);
|
||||||
var hashBytes = md5.ComputeHash(inputBytes);
|
var hashBytes = md5.ComputeHash(inputBytes);
|
||||||
return Convert.ToHexString(hashBytes);
|
return Convert.ToHexString(hashBytes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FreeSql" />
|
<PackageReference Include="FreeSql" />
|
||||||
|
<PackageReference Include="FreeSql.Extensions.JsonMap" />
|
||||||
<PackageReference Include="Yitter.IdGenerator" />
|
<PackageReference Include="Yitter.IdGenerator" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
global using FreeSql.DataAnnotations;
|
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 dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
|
||||||
var result = new List<dynamic>();
|
var result = new List<dynamic>();
|
||||||
|
|
||||||
foreach (System.Data.DataRow row in dt.Rows)
|
foreach (DataRow row in dt.Rows)
|
||||||
{
|
{
|
||||||
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
|
var expando = new ExpandoObject() as IDictionary<string, object>;
|
||||||
foreach (System.Data.DataColumn column in dt.Columns)
|
foreach (DataColumn column in dt.Columns)
|
||||||
{
|
{
|
||||||
expando[column.ColumnName] = row[column];
|
expando[column.ColumnName] = row[column];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,21 @@
|
|||||||
|
|
||||||
public interface IBudgetArchiveRepository : IBaseRepository<BudgetArchive>
|
public interface IBudgetArchiveRepository : IBaseRepository<BudgetArchive>
|
||||||
{
|
{
|
||||||
Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month);
|
Task<BudgetArchive?> GetArchiveAsync(int year, int month);
|
||||||
|
|
||||||
Task<List<BudgetArchive>> GetListAsync(int year, int month);
|
Task<List<BudgetArchive>> GetListAsync(int year, int month);
|
||||||
|
|
||||||
|
Task<List<BudgetArchive>> GetArchivesByYearAsync(int year);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BudgetArchiveRepository(
|
public class BudgetArchiveRepository(
|
||||||
IFreeSql freeSql
|
IFreeSql freeSql
|
||||||
) : BaseRepository<BudgetArchive>(freeSql), IBudgetArchiveRepository
|
) : BaseRepository<BudgetArchive>(freeSql), IBudgetArchiveRepository
|
||||||
{
|
{
|
||||||
public async Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month)
|
public async Task<BudgetArchive?> GetArchiveAsync(int year, int month)
|
||||||
{
|
{
|
||||||
return await FreeSql.Select<BudgetArchive>()
|
return await FreeSql.Select<BudgetArchive>()
|
||||||
.Where(a => a.BudgetId == budgetId &&
|
.Where(a => a.Year == year &&
|
||||||
a.Year == year &&
|
|
||||||
a.Month == month)
|
a.Month == month)
|
||||||
.ToOneAsync();
|
.ToOneAsync();
|
||||||
}
|
}
|
||||||
@@ -22,13 +24,15 @@ public class BudgetArchiveRepository(
|
|||||||
public async Task<List<BudgetArchive>> GetListAsync(int year, int month)
|
public async Task<List<BudgetArchive>> GetListAsync(int year, int month)
|
||||||
{
|
{
|
||||||
return await FreeSql.Select<BudgetArchive>()
|
return await FreeSql.Select<BudgetArchive>()
|
||||||
.Where(
|
.Where(a => a.Year == year && a.Month == month)
|
||||||
a => a.BudgetType == BudgetPeriodType.Month &&
|
.ToListAsync();
|
||||||
a.Year == year &&
|
}
|
||||||
a.Month == month ||
|
|
||||||
a.BudgetType == BudgetPeriodType.Year &&
|
public async Task<List<BudgetArchive>> GetArchivesByYearAsync(int year)
|
||||||
a.Year == year
|
{
|
||||||
)
|
return await FreeSql.Select<BudgetArchive>()
|
||||||
|
.Where(a => a.Year == year)
|
||||||
|
.OrderBy(a => a.Month)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,10 +28,6 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
|
|||||||
{
|
{
|
||||||
query = query.Where(t => t.Type == TransactionType.Income);
|
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);
|
return await query.SumAsync(t => t.Amount);
|
||||||
}
|
}
|
||||||
@@ -41,8 +37,7 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
|
|||||||
var records = await FreeSql.Select<BudgetRecord>()
|
var records = await FreeSql.Select<BudgetRecord>()
|
||||||
.Where(b => b.SelectedCategories.Contains(oldName) &&
|
.Where(b => b.SelectedCategories.Contains(oldName) &&
|
||||||
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
|
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
|
||||||
(type == TransactionType.Income && b.Category == BudgetCategory.Income) ||
|
(type == TransactionType.Income && b.Category == BudgetCategory.Income)))
|
||||||
(type == TransactionType.None && b.Category == BudgetCategory.Savings)))
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
foreach (var record in records)
|
foreach (var record in records)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
global using Entity;
|
global using Entity;
|
||||||
global using FreeSql;
|
|
||||||
global using System.Linq;
|
global using System.Linq;
|
||||||
|
global using System.Data;
|
||||||
|
global using System.Dynamic;
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,18 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="year">年份</param>
|
/// <param name="year">年份</param>
|
||||||
/// <param name="month">月份</param>
|
/// <param name="month">月份</param>
|
||||||
/// <returns>每天的消费笔数和金额</returns>
|
/// <param name="savingClassify"></param>
|
||||||
Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsAsync(int year, int month);
|
/// <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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取指定日期范围内的交易记录
|
/// 获取指定日期范围内的交易记录
|
||||||
@@ -141,7 +151,6 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据关键词查询交易记录(模糊匹配Reason字段)
|
/// 根据关键词查询交易记录(模糊匹配Reason字段)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="keyword">关键词</param>
|
|
||||||
/// <returns>匹配的交易记录列表</returns>
|
/// <returns>匹配的交易记录列表</returns>
|
||||||
Task<List<TransactionRecord>> QueryByWhereAsync(string sql);
|
Task<List<TransactionRecord>> QueryByWhereAsync(string sql);
|
||||||
|
|
||||||
@@ -190,6 +199,16 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// <returns>影响行数</returns>
|
/// <returns>影响行数</returns>
|
||||||
Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
|
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>
|
||||||
/// 更新分类名称
|
/// 更新分类名称
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -198,6 +217,8 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// <param name="type">交易类型</param>
|
/// <param name="type">交易类型</param>
|
||||||
/// <returns>影响行数</returns>
|
/// <returns>影响行数</returns>
|
||||||
Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type);
|
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
|
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
||||||
@@ -241,7 +262,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
t => t.Reason == reason);
|
t => t.Reason == reason);
|
||||||
|
|
||||||
// 按分类筛选
|
// 按分类筛选
|
||||||
if (classifies != null && classifies.Length > 0)
|
if (classifies is { Length: > 0 })
|
||||||
{
|
{
|
||||||
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
||||||
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
||||||
@@ -272,8 +293,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.Page(pageIndex, pageSize)
|
.Page(pageIndex, pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
// 按时间降序排列
|
// 按时间降序排列
|
||||||
return await query
|
return await query
|
||||||
.OrderByDescending(t => t.OccurredAt)
|
.OrderByDescending(t => t.OccurredAt)
|
||||||
@@ -281,7 +301,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.Page(pageIndex, pageSize)
|
.Page(pageIndex, pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<long> GetTotalCountAsync(
|
public async Task<long> GetTotalCountAsync(
|
||||||
string? searchKeyword = null,
|
string? searchKeyword = null,
|
||||||
@@ -305,7 +324,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
t => t.Reason == reason);
|
t => t.Reason == reason);
|
||||||
|
|
||||||
// 按分类筛选
|
// 按分类筛选
|
||||||
if (classifies != null && classifies.Length > 0)
|
if (classifies is { Length: > 0 })
|
||||||
{
|
{
|
||||||
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
||||||
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
||||||
@@ -337,11 +356,16 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.ToListAsync(t => t.Classify);
|
.ToListAsync(t => t.Classify);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsAsync(int year, int month)
|
public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null)
|
||||||
{
|
{
|
||||||
var startDate = new DateTime(year, month, 1);
|
var startDate = new DateTime(year, month, 1);
|
||||||
var endDate = startDate.AddMonths(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>()
|
var records = await FreeSql.Select<TransactionRecord>()
|
||||||
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate)
|
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -353,11 +377,16 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
g =>
|
g =>
|
||||||
{
|
{
|
||||||
// 分别统计收入和支出
|
// 分别统计收入和支出
|
||||||
var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
|
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 => t.Amount);
|
var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount));
|
||||||
// 净额 = 收入 - 支出(消费大于收入时为负数)
|
|
||||||
var netAmount = income - expense;
|
var saving = 0m;
|
||||||
return (count: g.Count(), amount: netAmount);
|
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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -443,9 +472,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
result.Add(new ReasonGroupDto
|
result.Add(new ReasonGroupDto
|
||||||
{
|
{
|
||||||
Reason = group.Reason,
|
Reason = group.Reason,
|
||||||
Count = (int)group.Count,
|
Count = group.Count,
|
||||||
SampleType = sample.Type,
|
SampleType = sample.Type,
|
||||||
SampleClassify = sample.Classify ?? string.Empty,
|
SampleClassify = sample.Classify,
|
||||||
TransactionIds = records.Select(r => r.Id).ToList(),
|
TransactionIds = records.Select(r => r.Id).ToList(),
|
||||||
TotalAmount = Math.Abs(group.TotalAmount)
|
TotalAmount = Math.Abs(group.TotalAmount)
|
||||||
});
|
});
|
||||||
@@ -499,19 +528,11 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
{
|
{
|
||||||
statistics.TotalExpense += amount;
|
statistics.TotalExpense += amount;
|
||||||
statistics.ExpenseCount++;
|
statistics.ExpenseCount++;
|
||||||
if (amount > statistics.MaxExpense)
|
|
||||||
{
|
|
||||||
statistics.MaxExpense = amount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (record.Type == TransactionType.Income)
|
else if (record.Type == TransactionType.Income)
|
||||||
{
|
{
|
||||||
statistics.TotalIncome += amount;
|
statistics.TotalIncome += amount;
|
||||||
statistics.IncomeCount++;
|
statistics.IncomeCount++;
|
||||||
if (amount > statistics.MaxIncome)
|
|
||||||
{
|
|
||||||
statistics.MaxIncome = amount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +552,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var categoryGroups = records
|
var categoryGroups = records
|
||||||
.GroupBy(t => t.Classify ?? "未分类")
|
.GroupBy(t => t.Classify)
|
||||||
.Select(g => new CategoryStatistics
|
.Select(g => new CategoryStatistics
|
||||||
{
|
{
|
||||||
Classify = g.Key,
|
Classify = g.Key,
|
||||||
@@ -595,9 +616,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
|
|
||||||
public async Task<List<TransactionRecord>> GetClassifiedByKeywordsAsync(List<string> keywords, int limit = 10)
|
public async Task<List<TransactionRecord>> GetClassifiedByKeywordsAsync(List<string> keywords, int limit = 10)
|
||||||
{
|
{
|
||||||
if (keywords == null || keywords.Count == 0)
|
if (keywords.Count == 0)
|
||||||
{
|
{
|
||||||
return new List<TransactionRecord>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var query = FreeSql.Select<TransactionRecord>()
|
var query = FreeSql.Select<TransactionRecord>()
|
||||||
@@ -617,9 +638,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)
|
public async Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10)
|
||||||
{
|
{
|
||||||
if (keywords == null || keywords.Count == 0)
|
if (keywords.Count == 0)
|
||||||
{
|
{
|
||||||
return new List<(TransactionRecord, double)>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询所有已分类且包含任意关键词的账单
|
// 查询所有已分类且包含任意关键词的账单
|
||||||
@@ -667,7 +688,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
|
|
||||||
if (currentRecord == null)
|
if (currentRecord == null)
|
||||||
{
|
{
|
||||||
return new List<TransactionRecord>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var list = await FreeSql.Select<TransactionRecord>()
|
var list = await FreeSql.Select<TransactionRecord>()
|
||||||
@@ -678,7 +699,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return list.OrderBy(t => Math.Abs(Math.Abs(t.Amount) - absAmount))
|
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();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,6 +730,50 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.Where(t => ids.Contains(t.Id))
|
.Where(t => ids.Contains(t.Id))
|
||||||
.ExecuteAffrowsAsync();
|
.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>
|
/// <summary>
|
||||||
@@ -739,7 +804,7 @@ public class ReasonGroupDto
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 该分组的所有账单ID列表
|
/// 该分组的所有账单ID列表
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<long> TransactionIds { get; set; } = new();
|
public List<long> TransactionIds { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 该分组的总金额(绝对值)
|
/// 该分组的总金额(绝对值)
|
||||||
@@ -753,15 +818,20 @@ public class ReasonGroupDto
|
|||||||
public class MonthlyStatistics
|
public class MonthlyStatistics
|
||||||
{
|
{
|
||||||
public int Year { get; set; }
|
public int Year { get; set; }
|
||||||
|
|
||||||
public int Month { get; set; }
|
public int Month { get; set; }
|
||||||
|
|
||||||
public decimal TotalExpense { get; set; }
|
public decimal TotalExpense { get; set; }
|
||||||
|
|
||||||
public decimal TotalIncome { get; set; }
|
public decimal TotalIncome { get; set; }
|
||||||
|
|
||||||
public decimal Balance { get; set; }
|
public decimal Balance { get; set; }
|
||||||
|
|
||||||
public int ExpenseCount { get; set; }
|
public int ExpenseCount { get; set; }
|
||||||
|
|
||||||
public int IncomeCount { get; set; }
|
public int IncomeCount { get; set; }
|
||||||
|
|
||||||
public int TotalCount { get; set; }
|
public int TotalCount { get; set; }
|
||||||
public decimal MaxExpense { get; set; }
|
|
||||||
public decimal MaxIncome { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -770,8 +840,11 @@ public class MonthlyStatistics
|
|||||||
public class CategoryStatistics
|
public class CategoryStatistics
|
||||||
{
|
{
|
||||||
public string Classify { get; set; } = string.Empty;
|
public string Classify { get; set; } = string.Empty;
|
||||||
|
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
public int Count { get; set; }
|
public int Count { get; set; }
|
||||||
|
|
||||||
public decimal Percent { get; set; }
|
public decimal Percent { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,8 +854,12 @@ public class CategoryStatistics
|
|||||||
public class TrendStatistics
|
public class TrendStatistics
|
||||||
{
|
{
|
||||||
public int Year { get; set; }
|
public int Year { get; set; }
|
||||||
|
|
||||||
public int Month { get; set; }
|
public int Month { get; set; }
|
||||||
|
|
||||||
public decimal Expense { get; set; }
|
public decimal Expense { get; set; }
|
||||||
|
|
||||||
public decimal Income { get; set; }
|
public decimal Income { get; set; }
|
||||||
|
|
||||||
public decimal Balance { get; set; }
|
public decimal Balance { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace Service.AppSettingModel;
|
namespace Service.AppSettingModel;
|
||||||
|
|
||||||
public class AISettings
|
public class AiSettings
|
||||||
{
|
{
|
||||||
public string Endpoint { get; set; } = string.Empty;
|
public string Endpoint { get; set; } = string.Empty;
|
||||||
public string Key { get; set; } = string.Empty;
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|||||||
871
Service/Budget/BudgetSavingsService.cs
Normal file
871
Service/Budget/BudgetSavingsService.cs
Normal file
@@ -0,0 +1,871 @@
|
|||||||
|
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) + "月";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
538
Service/Budget/BudgetService.cs
Normal file
538
Service/Budget/BudgetService.cs
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
940
Service/Budget/BudgetStatsService.cs
Normal file
940
Service/Budget/BudgetStatsService.cs
Normal file
@@ -0,0 +1,940 @@
|
|||||||
|
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; } // 剩余月份数,用于年度统计时的月度预算折算
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,728 +0,0 @@
|
|||||||
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 config = await configRepository.GetByKeyAsync(key);
|
||||||
var type = typeof(T) switch
|
var type = typeof(T) switch
|
||||||
{
|
{
|
||||||
Type t when t == typeof(bool) => ConfigType.Boolean,
|
{ } t when t == typeof(bool) => ConfigType.Boolean,
|
||||||
Type t when t == typeof(int)
|
{ } t when t == typeof(int)
|
||||||
|| t == typeof(double)
|
|| t == typeof(double)
|
||||||
|| t == typeof(float)
|
|| t == typeof(float)
|
||||||
|| t == typeof(decimal) => ConfigType.Number,
|
|| t == typeof(decimal) => ConfigType.Number,
|
||||||
Type t when t == typeof(string) => ConfigType.String,
|
{ } t when t == typeof(string) => ConfigType.String,
|
||||||
_ => ConfigType.Json
|
_ => ConfigType.Json
|
||||||
};
|
};
|
||||||
var valueStr = type switch
|
var valueStr = type switch
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Service.EmailParseServices;
|
using Service.EmailServices.EmailParse;
|
||||||
|
|
||||||
namespace Service.EmailServices;
|
namespace Service.EmailServices;
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ public class EmailHandleService(
|
|||||||
await messageService.AddAsync(
|
await messageService.AddAsync(
|
||||||
"邮件解析失败",
|
"邮件解析失败",
|
||||||
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。",
|
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。",
|
||||||
url: $"/balance?tab=email"
|
url: "/balance?tab=email"
|
||||||
);
|
);
|
||||||
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
|
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Service.EmailParseServices;
|
namespace Service.EmailServices.EmailParse;
|
||||||
|
|
||||||
public class EmailParseForm95555(
|
public class EmailParseForm95555(
|
||||||
ILogger<EmailParseForm95555> logger,
|
ILogger<EmailParseForm95555> logger,
|
||||||
@@ -26,7 +26,7 @@ public class EmailParseForm95555(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<(
|
public override Task<(
|
||||||
string card,
|
string card,
|
||||||
string reason,
|
string reason,
|
||||||
decimal amount,
|
decimal amount,
|
||||||
@@ -51,7 +51,7 @@ public class EmailParseForm95555(
|
|||||||
if (matches.Count <= 0)
|
if (matches.Count <= 0)
|
||||||
{
|
{
|
||||||
logger.LogWarning("未能从招商银行邮件内容中解析出交易信息");
|
logger.LogWarning("未能从招商银行邮件内容中解析出交易信息");
|
||||||
return [];
|
return Task.FromResult<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]>([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var results = new List<(
|
var results = new List<(
|
||||||
@@ -85,7 +85,7 @@ public class EmailParseForm95555(
|
|||||||
results.Add((card, reason, amount, balance, type, occurredAt));
|
results.Add((card, reason, amount, balance, type, occurredAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results.ToArray();
|
return Task.FromResult(results.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
private DateTime? ParseOccurredAt(string value)
|
private DateTime? ParseOccurredAt(string value)
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
using HtmlAgilityPack;
|
using HtmlAgilityPack;
|
||||||
|
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||||
|
// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
|
||||||
|
|
||||||
namespace Service.EmailParseServices;
|
namespace Service.EmailServices.EmailParse;
|
||||||
|
|
||||||
public class EmailParseFormCCSVC(
|
[UsedImplicitly]
|
||||||
ILogger<EmailParseFormCCSVC> logger,
|
public partial class EmailParseFormCcsvc(
|
||||||
|
ILogger<EmailParseFormCcsvc> logger,
|
||||||
IOpenAiService openAiService
|
IOpenAiService openAiService
|
||||||
) : EmailParseServicesBase(logger, openAiService)
|
) : EmailParseServicesBase(logger, openAiService)
|
||||||
{
|
{
|
||||||
|
[GeneratedRegex("<.*?>")]
|
||||||
|
private static partial Regex HtmlRegex();
|
||||||
|
|
||||||
public override bool CanParse(string from, string subject, string body)
|
public override bool CanParse(string from, string subject, string body)
|
||||||
{
|
{
|
||||||
if (!from.Contains("ccsvc@message.cmbchina.com"))
|
if (!from.Contains("ccsvc@message.cmbchina.com"))
|
||||||
@@ -20,12 +26,7 @@ public class EmailParseFormCCSVC(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 必须包含HTML标签
|
// 必须包含HTML标签
|
||||||
if (!Regex.IsMatch(body, "<.*?>"))
|
return HtmlRegex().IsMatch(body);
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<(
|
public override async Task<(
|
||||||
@@ -47,7 +48,7 @@ public class EmailParseFormCCSVC(
|
|||||||
if (dateNode == null)
|
if (dateNode == null)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Date node not found");
|
logger.LogWarning("Date node not found");
|
||||||
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var dateText = dateNode.InnerText.Trim();
|
var dateText = dateNode.InnerText.Trim();
|
||||||
@@ -56,7 +57,7 @@ public class EmailParseFormCCSVC(
|
|||||||
if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date))
|
if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date))
|
||||||
{
|
{
|
||||||
logger.LogWarning("Failed to parse date from: {DateText}", dateText);
|
logger.LogWarning("Failed to parse date from: {DateText}", dateText);
|
||||||
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get Balance (Available Limit)
|
// 2. Get Balance (Available Limit)
|
||||||
@@ -90,6 +91,7 @@ public class EmailParseFormCCSVC(
|
|||||||
{
|
{
|
||||||
foreach (var node in transactionNodes)
|
foreach (var node in transactionNodes)
|
||||||
{
|
{
|
||||||
|
string card = "";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Time
|
// Time
|
||||||
@@ -122,23 +124,16 @@ public class EmailParseFormCCSVC(
|
|||||||
descText = HtmlEntity.DeEntitize(descText).Replace((char)160, ' ').Trim();
|
descText = HtmlEntity.DeEntitize(descText).Replace((char)160, ' ').Trim();
|
||||||
|
|
||||||
// Parse Description: "尾号4390 消费 财付通-luckincoffee瑞幸咖啡"
|
// Parse Description: "尾号4390 消费 财付通-luckincoffee瑞幸咖啡"
|
||||||
var parts = descText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
var parts = descText.Split([' '], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
string card = "";
|
var reason = descText;
|
||||||
string reason = descText;
|
TransactionType type;
|
||||||
TransactionType type = TransactionType.Expense;
|
|
||||||
|
|
||||||
if (parts.Length > 0 && parts[0].StartsWith("尾号"))
|
if (parts.Length > 0 && parts[0].StartsWith("尾号"))
|
||||||
{
|
{
|
||||||
card = parts[0].Replace("尾号", "");
|
card = parts[0].Replace("尾号", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.Length > 1)
|
|
||||||
{
|
|
||||||
var typeStr = parts[1];
|
|
||||||
type = DetermineTransactionType(typeStr, reason, amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.Length > 2)
|
if (parts.Length > 2)
|
||||||
{
|
{
|
||||||
reason = string.Join(" ", parts.Skip(2));
|
reason = string.Join(" ", parts.Skip(2));
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Service.EmailParseServices;
|
namespace Service.EmailServices.EmailParse;
|
||||||
|
|
||||||
public interface IEmailParseServices
|
public interface IEmailParseServices
|
||||||
{
|
{
|
||||||
@@ -201,7 +201,7 @@ public abstract class EmailParseServicesBase(
|
|||||||
|
|
||||||
// 收入关键词
|
// 收入关键词
|
||||||
string[] incomeKeywords =
|
string[] incomeKeywords =
|
||||||
{
|
[
|
||||||
"工资", "奖金", "退款",
|
"工资", "奖金", "退款",
|
||||||
"返现", "收入", "转入",
|
"返现", "收入", "转入",
|
||||||
"存入", "利息", "分红",
|
"存入", "利息", "分红",
|
||||||
@@ -233,13 +233,13 @@ public abstract class EmailParseServicesBase(
|
|||||||
// 存取类
|
// 存取类
|
||||||
"现金存入", "柜台存入", "ATM存入",
|
"现金存入", "柜台存入", "ATM存入",
|
||||||
"他人转入", "他人汇入"
|
"他人转入", "他人汇入"
|
||||||
};
|
];
|
||||||
if (incomeKeywords.Any(k => lowerReason.Contains(k)))
|
if (incomeKeywords.Any(k => lowerReason.Contains(k)))
|
||||||
return TransactionType.Income;
|
return TransactionType.Income;
|
||||||
|
|
||||||
// 支出关键词
|
// 支出关键词
|
||||||
string[] expenseKeywords =
|
string[] expenseKeywords =
|
||||||
{
|
[
|
||||||
"消费", "支付", "购买",
|
"消费", "支付", "购买",
|
||||||
"转出", "取款", "支出",
|
"转出", "取款", "支出",
|
||||||
"扣款", "缴费", "付款",
|
"扣款", "缴费", "付款",
|
||||||
@@ -269,7 +269,7 @@ public abstract class EmailParseServicesBase(
|
|||||||
// 信用卡/花呗等场景
|
// 信用卡/花呗等场景
|
||||||
"信用卡还款", "花呗还款", "白条还款",
|
"信用卡还款", "花呗还款", "白条还款",
|
||||||
"分期还款", "账单还款", "自动还款"
|
"分期还款", "账单还款", "自动还款"
|
||||||
};
|
];
|
||||||
if (expenseKeywords.Any(k => lowerReason.Contains(k)))
|
if (expenseKeywords.Any(k => lowerReason.Contains(k)))
|
||||||
return TransactionType.Expense;
|
return TransactionType.Expense;
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ public class EmailSyncService(
|
|||||||
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
||||||
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
||||||
|
|
||||||
|
// ReSharper disable once UnusedVariable
|
||||||
foreach (var (message, uid) in unreadMessages)
|
foreach (var (message, uid) in unreadMessages)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ global using System.Globalization;
|
|||||||
global using System.Text;
|
global using System.Text;
|
||||||
global using System.Text.Json;
|
global using System.Text.Json;
|
||||||
global using Entity;
|
global using Entity;
|
||||||
global using FreeSql;
|
|
||||||
global using System.Linq;
|
global using System.Linq;
|
||||||
global using Service.AppSettingModel;
|
global using Service.AppSettingModel;
|
||||||
global using System.Text.Json.Serialization;
|
global using System.Text.Json.Serialization;
|
||||||
global using System.Text.Json.Nodes;
|
global using System.Text.Json.Nodes;
|
||||||
global using Microsoft.Extensions.Configuration;
|
global using Microsoft.Extensions.Configuration;
|
||||||
global using Common;
|
global using Common;
|
||||||
|
global using System.Net;
|
||||||
|
global using System.Text.Encodings.Web;
|
||||||
|
global using JetBrains.Annotations;
|
||||||
@@ -133,7 +133,7 @@ public class ImportService(
|
|||||||
return DateTime.MinValue;
|
return DateTime.MinValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var format in DateTimeFormats)
|
foreach (var format in _dateTimeFormats)
|
||||||
{
|
{
|
||||||
if (DateTime.TryParseExact(
|
if (DateTime.TryParseExact(
|
||||||
row[key],
|
row[key],
|
||||||
@@ -288,7 +288,7 @@ public class ImportService(
|
|||||||
return DateTime.MinValue;
|
return DateTime.MinValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var format in DateTimeFormats)
|
foreach (var format in _dateTimeFormats)
|
||||||
{
|
{
|
||||||
if (DateTime.TryParseExact(
|
if (DateTime.TryParseExact(
|
||||||
row[key],
|
row[key],
|
||||||
@@ -358,15 +358,14 @@ public class ImportService(
|
|||||||
{
|
{
|
||||||
return await ParseCsvAsync(file);
|
return await ParseCsvAsync(file);
|
||||||
}
|
}
|
||||||
else if (fileExtension == ".xlsx" || fileExtension == ".xls")
|
|
||||||
|
if (fileExtension == ".xlsx" || fileExtension == ".xls")
|
||||||
{
|
{
|
||||||
return await ParseExcelAsync(file);
|
return await ParseExcelAsync(file);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new NotSupportedException("不支持的文件格式");
|
throw new NotSupportedException("不支持的文件格式");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IDictionary<string, string>[]> ParseCsvAsync(MemoryStream file)
|
private async Task<IDictionary<string, string>[]> ParseCsvAsync(MemoryStream file)
|
||||||
{
|
{
|
||||||
@@ -388,7 +387,7 @@ public class ImportService(
|
|||||||
|
|
||||||
if (headers == null || headers.Length == 0)
|
if (headers == null || headers.Length == 0)
|
||||||
{
|
{
|
||||||
return Array.Empty<IDictionary<string, string>>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = new List<IDictionary<string, string>>();
|
var result = new List<IDictionary<string, string>>();
|
||||||
@@ -420,7 +419,7 @@ public class ImportService(
|
|||||||
|
|
||||||
if (worksheet == null || worksheet.Dimension == null)
|
if (worksheet == null || worksheet.Dimension == null)
|
||||||
{
|
{
|
||||||
return Array.Empty<IDictionary<string, string>>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var rowCount = worksheet.Dimension.End.Row;
|
var rowCount = worksheet.Dimension.End.Row;
|
||||||
@@ -428,7 +427,7 @@ public class ImportService(
|
|||||||
|
|
||||||
if (rowCount < 2)
|
if (rowCount < 2)
|
||||||
{
|
{
|
||||||
return Array.Empty<IDictionary<string, string>>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取表头(第一行)
|
// 读取表头(第一行)
|
||||||
@@ -458,7 +457,7 @@ public class ImportService(
|
|||||||
return await Task.FromResult(result.ToArray());
|
return await Task.FromResult(result.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string[] DateTimeFormats =
|
private static string[] _dateTimeFormats =
|
||||||
[
|
[
|
||||||
"yyyy-MM-dd",
|
"yyyy-MM-dd",
|
||||||
"yyyy-MM-dd HH",
|
"yyyy-MM-dd HH",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Quartz;
|
using Quartz;
|
||||||
|
using Service.Budget;
|
||||||
|
|
||||||
namespace Service.Jobs;
|
namespace Service.Jobs;
|
||||||
|
|
||||||
@@ -23,6 +24,8 @@ public class BudgetArchiveJob(
|
|||||||
|
|
||||||
using var scope = serviceProvider.CreateScope();
|
using var scope = serviceProvider.CreateScope();
|
||||||
var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>();
|
var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>();
|
||||||
|
|
||||||
|
// 归档月度数据
|
||||||
var result = await budgetService.ArchiveBudgetsAsync(year, month);
|
var result = await budgetService.ArchiveBudgetsAsync(year, month);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(result))
|
if (string.IsNullOrEmpty(result))
|
||||||
|
|||||||
70
Service/Jobs/DbBackupJob.cs
Normal file
70
Service/Jobs/DbBackupJob.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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,6 +127,7 @@ public class EmailSyncJob(
|
|||||||
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
||||||
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
||||||
|
|
||||||
|
// ReSharper disable once UnusedVariable
|
||||||
foreach (var (message, uid) in unreadMessages)
|
foreach (var (message, uid) in unreadMessages)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ public class LogCleanupService(ILogger<LogCleanupService> logger) : BackgroundSe
|
|||||||
|
|
||||||
// 尝试解析日期 (格式: yyyyMMdd)
|
// 尝试解析日期 (格式: yyyyMMdd)
|
||||||
if (DateTime.TryParseExact(dateStr, "yyyyMMdd",
|
if (DateTime.TryParseExact(dateStr, "yyyyMMdd",
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
System.Globalization.DateTimeStyles.None,
|
DateTimeStyles.None,
|
||||||
out var logDate))
|
out var logDate))
|
||||||
{
|
{
|
||||||
if (logDate < cutoffDate)
|
if (logDate < cutoffDate)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using WebPush;
|
using WebPush;
|
||||||
|
using PushSubscription = Entity.PushSubscription;
|
||||||
|
|
||||||
namespace Service;
|
namespace Service;
|
||||||
|
|
||||||
public interface INotificationService
|
public interface INotificationService
|
||||||
{
|
{
|
||||||
Task<string> GetVapidPublicKeyAsync();
|
Task<string> GetVapidPublicKeyAsync();
|
||||||
Task SubscribeAsync(Entity.PushSubscription subscription);
|
Task SubscribeAsync(PushSubscription subscription);
|
||||||
Task SendNotificationAsync(string message, string? url = null);
|
Task SendNotificationAsync(string message, string? url = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ public class NotificationService(
|
|||||||
return Task.FromResult(GetSettings().PublicKey);
|
return Task.FromResult(GetSettings().PublicKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SubscribeAsync(Entity.PushSubscription subscription)
|
public async Task SubscribeAsync(PushSubscription subscription)
|
||||||
{
|
{
|
||||||
var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint);
|
var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint);
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
@@ -61,7 +62,7 @@ public class NotificationService(
|
|||||||
var webPushClient = new WebPushClient();
|
var webPushClient = new WebPushClient();
|
||||||
|
|
||||||
var subscriptions = await subscriptionRepo.GetAllAsync();
|
var subscriptions = await subscriptionRepo.GetAllAsync();
|
||||||
var payload = System.Text.Json.JsonSerializer.Serialize(new
|
var payload = JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
title = "System Notification",
|
title = "System Notification",
|
||||||
body = message,
|
body = message,
|
||||||
@@ -78,7 +79,7 @@ public class NotificationService(
|
|||||||
}
|
}
|
||||||
catch (WebPushException ex)
|
catch (WebPushException ex)
|
||||||
{
|
{
|
||||||
if (ex.StatusCode == System.Net.HttpStatusCode.Gone || ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
if (ex.StatusCode == HttpStatusCode.Gone || ex.StatusCode == HttpStatusCode.NotFound)
|
||||||
{
|
{
|
||||||
await subscriptionRepo.DeleteAsync(sub.Id);
|
await subscriptionRepo.DeleteAsync(sub.Id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public interface IOpenAiService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public class OpenAiService(
|
public class OpenAiService(
|
||||||
IOptions<AISettings> aiSettings,
|
IOptions<AiSettings> aiSettings,
|
||||||
ILogger<OpenAiService> logger
|
ILogger<OpenAiService> logger
|
||||||
) : IOpenAiService
|
) : IOpenAiService
|
||||||
{
|
{
|
||||||
@@ -158,10 +158,8 @@ public class OpenAiService(
|
|||||||
var json = JsonSerializer.Serialize(payload);
|
var json = JsonSerializer.Serialize(payload);
|
||||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||||
{
|
request.Content = content;
|
||||||
Content = content
|
|
||||||
};
|
|
||||||
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
@@ -232,10 +230,8 @@ public class OpenAiService(
|
|||||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
// 使用 SendAsync 来支持 HttpCompletionOption
|
// 使用 SendAsync 来支持 HttpCompletionOption
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||||
{
|
request.Content = content;
|
||||||
Content = content
|
|
||||||
};
|
|
||||||
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="JetBrains.Annotations" />
|
||||||
<PackageReference Include="MailKit" />
|
<PackageReference Include="MailKit" />
|
||||||
<PackageReference Include="MimeKit" />
|
<PackageReference Include="MimeKit" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ public class SmartHandleService(
|
|||||||
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
|
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
|
||||||
|
|
||||||
var classifyResults = new List<(string Reason, string Classify, TransactionType Type)>();
|
var classifyResults = new List<(string Reason, string Classify, TransactionType Type)>();
|
||||||
var sendedIds = new HashSet<long>();
|
var sentIds = new HashSet<long>();
|
||||||
|
|
||||||
// 将流解析逻辑提取为本地函数以减少嵌套
|
// 将流解析逻辑提取为本地函数以减少嵌套
|
||||||
void HandleResult(GroupClassifyResult? result)
|
void HandleResult(GroupClassifyResult? result)
|
||||||
@@ -154,8 +154,11 @@ public class SmartHandleService(
|
|||||||
if (group == null) return;
|
if (group == null) return;
|
||||||
foreach (var id in group.Ids)
|
foreach (var id in group.Ids)
|
||||||
{
|
{
|
||||||
if (sendedIds.Add(id))
|
if (!sentIds.Add(id))
|
||||||
{
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var resultJson = JsonSerializer.Serialize(new
|
var resultJson = JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -165,7 +168,6 @@ public class SmartHandleService(
|
|||||||
chunkAction(("data", resultJson));
|
chunkAction(("data", resultJson));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 解析缓冲区中的所有完整 JSON 对象或数组
|
// 解析缓冲区中的所有完整 JSON 对象或数组
|
||||||
void FlushBuffer(StringBuilder buffer)
|
void FlushBuffer(StringBuilder buffer)
|
||||||
@@ -193,7 +195,7 @@ public class SmartHandleService(
|
|||||||
}
|
}
|
||||||
catch (Exception exArr)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,7 +338,7 @@ public class SmartHandleService(
|
|||||||
{
|
{
|
||||||
content = $"""
|
content = $"""
|
||||||
<pre style="max-height: 80px; font-size: 8px; overflow-y: auto; padding: 8px; border: 1px solid #3c3c3c">
|
<pre style="max-height: 80px; font-size: 8px; overflow-y: auto; padding: 8px; border: 1px solid #3c3c3c">
|
||||||
{System.Net.WebUtility.HtmlEncode(sqlText)}
|
{WebUtility.HtmlEncode(sqlText)}
|
||||||
</pre>
|
</pre>
|
||||||
"""
|
"""
|
||||||
})
|
})
|
||||||
@@ -361,7 +363,7 @@ public class SmartHandleService(
|
|||||||
var dataJson = JsonSerializer.Serialize(queryResults, new JsonSerializerOptions
|
var dataJson = JsonSerializer.Serialize(queryResults, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
});
|
});
|
||||||
|
|
||||||
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
|
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
|
||||||
@@ -429,7 +431,6 @@ public class SmartHandleService(
|
|||||||
{
|
{
|
||||||
// 获取所有分类
|
// 获取所有分类
|
||||||
var categories = await categoryRepository.GetAllAsync();
|
var categories = await categoryRepository.GetAllAsync();
|
||||||
var categoryList = string.Join("、", categories.Select(c => $"{GetTypeName(c.Type)}-{c.Name}"));
|
|
||||||
|
|
||||||
// 构建分类信息
|
// 构建分类信息
|
||||||
var categoryInfo = new StringBuilder();
|
var categoryInfo = new StringBuilder();
|
||||||
@@ -542,13 +543,13 @@ public class SmartHandleService(
|
|||||||
public record GroupClassifyResult
|
public record GroupClassifyResult
|
||||||
{
|
{
|
||||||
[JsonPropertyName("reason")]
|
[JsonPropertyName("reason")]
|
||||||
public string Reason { get; set; } = string.Empty;
|
public string Reason { get; init; } = string.Empty;
|
||||||
|
|
||||||
[JsonPropertyName("classify")]
|
[JsonPropertyName("classify")]
|
||||||
public string? Classify { get; set; }
|
public string? Classify { get; init; }
|
||||||
|
|
||||||
[JsonPropertyName("type")]
|
[JsonPropertyName("type")]
|
||||||
public TransactionType Type { get; set; }
|
public TransactionType Type { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);
|
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
namespace Service;
|
using JiebaNet.Analyser;
|
||||||
|
|
||||||
using JiebaNet.Segmenter;
|
using JiebaNet.Segmenter;
|
||||||
using JiebaNet.Analyser;
|
|
||||||
using Microsoft.Extensions.Logging;
|
namespace Service;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 文本分词服务接口
|
/// 文本分词服务接口
|
||||||
@@ -78,7 +77,7 @@ public class TextSegmentService : ITextSegmentService
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
return new List<string>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -119,7 +118,7 @@ public class TextSegmentService : ITextSegmentService
|
|||||||
{
|
{
|
||||||
_logger.LogError(ex, "提取关键词失败,文本: {Text}", text);
|
_logger.LogError(ex, "提取关键词失败,文本: {Text}", text);
|
||||||
// 降级处理:返回原文
|
// 降级处理:返回原文
|
||||||
return new List<string> { text.Length > 10 ? text.Substring(0, 10) : text };
|
return [text.Length > 10 ? text.Substring(0, 10) : text];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +126,7 @@ public class TextSegmentService : ITextSegmentService
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
return new List<string>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -146,7 +145,7 @@ public class TextSegmentService : ITextSegmentService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "分词失败,文本: {Text}", text);
|
_logger.LogError(ex, "分词失败,文本: {Text}", text);
|
||||||
return new List<string> { text };
|
return [text];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ public class TransactionPeriodicService(
|
|||||||
var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday
|
var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
.Where(d => d >= 0 && d <= 6)
|
.Where(d => d is >= 0 and <= 6)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return executeDays.Contains(dayOfWeek);
|
return executeDays.Contains(dayOfWeek);
|
||||||
@@ -160,7 +160,7 @@ public class TransactionPeriodicService(
|
|||||||
|
|
||||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
.Where(d => d >= 1 && d <= 31)
|
.Where(d => d is >= 1 and <= 31)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// 如果当前为月末,且配置中有大于当月天数的日期,则也执行
|
// 如果当前为月末,且配置中有大于当月天数的日期,则也执行
|
||||||
@@ -223,7 +223,7 @@ public class TransactionPeriodicService(
|
|||||||
|
|
||||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
.Where(d => d >= 0 && d <= 6)
|
.Where(d => d is >= 0 and <= 6)
|
||||||
.OrderBy(d => d)
|
.OrderBy(d => d)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ public class TransactionPeriodicService(
|
|||||||
|
|
||||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
.Where(d => d >= 1 && d <= 31)
|
.Where(d => d is >= 1 and <= 31)
|
||||||
.OrderBy(d => d)
|
.OrderBy(d => d)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,5 +2,6 @@
|
|||||||
"$schema": "https://json.schemastore.org/prettierrc",
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 100
|
"printWidth": 100,
|
||||||
|
"trailingComma": "none"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,82 @@
|
|||||||
import js from '@eslint/js'
|
import js from '@eslint/js'
|
||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
import pluginVue from 'eslint-plugin-vue'
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
|
||||||
|
|
||||||
export default [
|
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}'],
|
files: ['**/*.{js,mjs,jsx,vue}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser
|
||||||
},
|
},
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
sourceType: 'module',
|
sourceType: 'module'
|
||||||
},
|
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
// Import standard JS recommended rules
|
||||||
...js.configs.recommended.rules,
|
...js.configs.recommended.rules,
|
||||||
'indent': ['error', 2],
|
|
||||||
|
// --- 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 }],
|
||||||
'quotes': ['error', 'single', { avoidEscape: true }],
|
'quotes': ['error', 'single', { avoidEscape: true }],
|
||||||
'semi': ['error', 'never'],
|
'semi': ['error', 'never'],
|
||||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
|
||||||
'comma-dangle': ['error', 'never'],
|
'comma-dangle': ['error', 'never'],
|
||||||
'no-trailing-spaces': 'error',
|
'no-trailing-spaces': 'error',
|
||||||
'no-multiple-empty-lines': ['error', { max: 1 }],
|
'no-multiple-empty-lines': ['error', { max: 1 }],
|
||||||
'space-before-function-paren': ['error', 'always'],
|
'space-before-function-paren': ['error', 'always'],
|
||||||
|
'object-curly-spacing': ['error', 'always'],
|
||||||
|
'array-bracket-spacing': ['error', 'never']
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
|
||||||
...pluginVue.configs['flat/recommended'],
|
// Vue Specific Overrides
|
||||||
{
|
{
|
||||||
files: ['**/*.vue'],
|
files: ['**/*.vue'],
|
||||||
rules: {
|
rules: {
|
||||||
'vue/multi-word-component-names': 'off',
|
'vue/multi-word-component-names': 'off',
|
||||||
'vue/no-v-html': 'warn',
|
'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',
|
'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: []
|
||||||
|
}]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
|
||||||
skipFormatting,
|
// Service Worker specific globals
|
||||||
{
|
{
|
||||||
files: ['**/service-worker.js', '**/src/registerServiceWorker.js'],
|
files: ['**/service-worker.js', '**/src/registerServiceWorker.js'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
...globals.serviceworker,
|
...globals.serviceworker
|
||||||
...globals.browser,
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,11 +11,12 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --fix --cache",
|
"lint": "eslint . --fix --cache",
|
||||||
"format": "prettier --write --experimental-cli src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vant": "^4.9.22",
|
"vant": "^4.9.22",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
|
|||||||
23
Web/pnpm-lock.yaml
generated
23
Web/pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.19
|
specifier: ^1.11.19
|
||||||
version: 1.11.19
|
version: 1.11.19
|
||||||
|
echarts:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(vue@3.5.26)
|
version: 3.0.4(vue@3.5.26)
|
||||||
@@ -787,6 +790,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.267:
|
electron-to-chromium@1.5.267:
|
||||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||||
|
|
||||||
@@ -1296,6 +1302,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -1435,6 +1444,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@babel/code-frame@7.27.1':
|
'@babel/code-frame@7.27.1':
|
||||||
@@ -2131,6 +2143,11 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
zrender: 6.0.0
|
||||||
|
|
||||||
electron-to-chromium@1.5.267: {}
|
electron-to-chromium@1.5.267: {}
|
||||||
|
|
||||||
entities@7.0.0: {}
|
entities@7.0.0: {}
|
||||||
@@ -2611,6 +2628,8 @@ snapshots:
|
|||||||
|
|
||||||
totalist@3.0.1: {}
|
totalist@3.0.1: {}
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -2744,3 +2763,7 @@ snapshots:
|
|||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
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 VERSION = '1.0.0' // Build Time: 2026-01-07 15:59:36
|
||||||
const CACHE_NAME = `emailbill-${VERSION}`;
|
const CACHE_NAME = `emailbill-${VERSION}`
|
||||||
const urlsToCache = [
|
const urlsToCache = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
'/manifest.json'
|
'/manifest.json'
|
||||||
];
|
]
|
||||||
|
|
||||||
// 安装 Service Worker
|
// 安装 Service Worker
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
console.log('[Service Worker] 安装中...');
|
console.log('[Service Worker] 安装中...')
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
caches.open(CACHE_NAME)
|
||||||
.then((cache) => {
|
.then((cache) => {
|
||||||
console.log('[Service Worker] 缓存文件');
|
console.log('[Service Worker] 缓存文件')
|
||||||
return cache.addAll(urlsToCache);
|
return cache.addAll(urlsToCache)
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
// 监听跳过等待消息
|
// 监听跳过等待消息
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
self.skipWaiting();
|
self.skipWaiting()
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
// 激活 Service Worker
|
// 激活 Service Worker
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
console.log('[Service Worker] 激活中...');
|
console.log('[Service Worker] 激活中...')
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
caches.keys().then((cacheNames) => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
cacheNames.map((cacheName) => {
|
cacheNames.map((cacheName) => {
|
||||||
if (cacheName !== CACHE_NAME) {
|
if (cacheName !== CACHE_NAME) {
|
||||||
console.log('[Service Worker] 删除旧缓存:', cacheName);
|
console.log('[Service Worker] 删除旧缓存:', cacheName)
|
||||||
return caches.delete(cacheName);
|
return caches.delete(cacheName)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
}).then(() => self.clients.claim())
|
}).then(() => self.clients.claim())
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
// 拦截请求
|
// 拦截请求
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const { request } = event;
|
const { request } = event
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url)
|
||||||
|
|
||||||
// 跳过跨域请求
|
// 跳过跨域请求
|
||||||
if (url.origin !== location.origin) {
|
if (url.origin !== location.origin) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// API请求使用网络优先策略
|
// API请求使用网络优先策略
|
||||||
@@ -60,19 +60,19 @@ self.addEventListener('fetch', (event) => {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
// 只针对成功的GET请求进行缓存
|
// 只针对成功的GET请求进行缓存
|
||||||
if (request.method === 'GET' && response.status === 200) {
|
if (request.method === 'GET' && response.status === 200) {
|
||||||
const responseClone = response.clone();
|
const responseClone = response.clone()
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
cache.put(request, responseClone);
|
cache.put(request, responseClone)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
return response;
|
return response
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// 网络失败时尝试从缓存获取
|
// 网络失败时尝试从缓存获取
|
||||||
return caches.match(request);
|
return caches.match(request)
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面请求使用网络优先策略,确保能获取到最新的 index.html
|
// 页面请求使用网络优先策略,确保能获取到最新的 index.html
|
||||||
@@ -80,17 +80,17 @@ self.addEventListener('fetch', (event) => {
|
|||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(request)
|
fetch(request)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const responseClone = response.clone();
|
const responseClone = response.clone()
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
cache.put(request, responseClone);
|
cache.put(request, responseClone)
|
||||||
});
|
})
|
||||||
return response;
|
return response
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.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)
|
caches.match(request)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
return response;
|
return response
|
||||||
}
|
}
|
||||||
return fetch(request).then((response) => {
|
return fetch(request).then((response) => {
|
||||||
// 检查是否是有效响应
|
// 检查是否是有效响应
|
||||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
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) => {
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
cache.put(request, responseClone);
|
cache.put(request, responseClone)
|
||||||
});
|
})
|
||||||
|
|
||||||
return response;
|
return response
|
||||||
});
|
})
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// 返回离线页面或默认内容
|
// 返回离线页面或默认内容
|
||||||
if (request.destination === 'document') {
|
if (request.destination === 'document') {
|
||||||
return caches.match('/index.html');
|
return caches.match('/index.html')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
// 后台同步
|
// 后台同步
|
||||||
self.addEventListener('sync', (event) => {
|
self.addEventListener('sync', (event) => {
|
||||||
console.log('[Service Worker] 后台同步:', event.tag);
|
console.log('[Service Worker] 后台同步:', event.tag)
|
||||||
if (event.tag === 'sync-data') {
|
if (event.tag === 'sync-data') {
|
||||||
event.waitUntil(syncData());
|
event.waitUntil(syncData())
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
// 推送通知
|
// 推送通知
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
console.log('[Service Worker] 收到推送消息');
|
console.log('[Service Worker] 收到推送消息')
|
||||||
let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' };
|
let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' }
|
||||||
|
|
||||||
if (event.data) {
|
if (event.data) {
|
||||||
try {
|
try {
|
||||||
const json = event.data.json();
|
const json = event.data.json()
|
||||||
data = { ...data, ...json };
|
data = { ...data, ...json }
|
||||||
} catch {
|
} catch {
|
||||||
data.body = event.data.text();
|
data.body = event.data.text()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,41 +153,41 @@ self.addEventListener('push', (event) => {
|
|||||||
tag: 'emailbill-notification',
|
tag: 'emailbill-notification',
|
||||||
requireInteraction: false,
|
requireInteraction: false,
|
||||||
data: { url: data.url }
|
data: { url: data.url }
|
||||||
};
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(data.title, options)
|
self.registration.showNotification(data.title, options)
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
// 通知点击
|
// 通知点击
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
console.log('[Service Worker] 通知被点击');
|
console.log('[Service Worker] 通知被点击')
|
||||||
event.notification.close();
|
event.notification.close()
|
||||||
const urlToOpen = event.notification.data?.url || '/';
|
const urlToOpen = event.notification.data?.url || '/'
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||||
// 如果已经打开了该 URL,则聚焦
|
// 如果已经打开了该 URL,则聚焦
|
||||||
for (let i = 0; i < windowClients.length; i++) {
|
for (let i = 0; i < windowClients.length; i++) {
|
||||||
const client = windowClients[i];
|
const client = windowClients[i]
|
||||||
if (client.url === urlToOpen && 'focus' in client) {
|
if (client.url === urlToOpen && 'focus' in client) {
|
||||||
return client.focus();
|
return client.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 否则打开新窗口
|
// 否则打开新窗口
|
||||||
if (clients.openWindow) {
|
if (clients.openWindow) {
|
||||||
return clients.openWindow(urlToOpen);
|
return clients.openWindow(urlToOpen)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
// 数据同步函数
|
// 数据同步函数
|
||||||
async function syncData() {
|
async function syncData () {
|
||||||
try {
|
try {
|
||||||
// 这里添加需要同步的逻辑
|
// 这里添加需要同步的逻辑
|
||||||
console.log('[Service Worker] 执行数据同步');
|
console.log('[Service Worker] 执行数据同步')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Service Worker] 同步失败:', error);
|
console.error('[Service Worker] 同步失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
<div class="app-root">
|
<div class="app-root">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<van-tabbar v-show="showTabbar" v-model="active">
|
<van-tabbar v-show="showTabbar" v-model="active">
|
||||||
<van-tabbar-item name="ccalendar" icon="notes" to="/calendar">
|
<van-tabbar-item name="ccalendar" icon="notes" to="/calendar"> 日历 </van-tabbar-item>
|
||||||
日历
|
<van-tabbar-item
|
||||||
</van-tabbar-item>
|
name="statistics"
|
||||||
<van-tabbar-item name="statistics" icon="chart-trending-o" to="/" @click="handleTabClick('/statistics')">
|
icon="chart-trending-o"
|
||||||
|
to="/"
|
||||||
|
@click="handleTabClick('/statistics')"
|
||||||
|
>
|
||||||
统计
|
统计
|
||||||
</van-tabbar-item>
|
</van-tabbar-item>
|
||||||
<van-tabbar-item
|
<van-tabbar-item
|
||||||
@@ -18,14 +21,17 @@
|
|||||||
>
|
>
|
||||||
账单
|
账单
|
||||||
</van-tabbar-item>
|
</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>
|
||||||
<van-tabbar-item name="setting" icon="setting" to="/setting">
|
<van-tabbar-item name="setting" icon="setting" to="/setting"> 设置 </van-tabbar-item>
|
||||||
设置
|
|
||||||
</van-tabbar-item>
|
|
||||||
</van-tabbar>
|
</van-tabbar>
|
||||||
<GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess"/>
|
<GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess" />
|
||||||
|
|
||||||
<div v-if="needRefresh" class="update-toast" @click="updateServiceWorker">
|
<div v-if="needRefresh" class="update-toast" @click="updateServiceWorker">
|
||||||
<van-icon name="upgrade" class="update-icon" />
|
<van-icon name="upgrade" class="update-icon" />
|
||||||
@@ -85,12 +91,14 @@ onUnmounted(() => {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
// 根据路由判断是否显示Tabbar
|
// 根据路由判断是否显示Tabbar
|
||||||
const showTabbar = computed(() => {
|
const showTabbar = computed(() => {
|
||||||
return route.path === '/' ||
|
return (
|
||||||
|
route.path === '/' ||
|
||||||
route.path === '/calendar' ||
|
route.path === '/calendar' ||
|
||||||
route.path === '/message' ||
|
route.path === '/message' ||
|
||||||
route.path === '/setting' ||
|
route.path === '/setting' ||
|
||||||
route.path === '/balance' ||
|
route.path === '/balance' ||
|
||||||
route.path === '/budget'
|
route.path === '/budget'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const active = ref('')
|
const active = ref('')
|
||||||
@@ -116,11 +124,14 @@ setInterval(() => {
|
|||||||
}, 60 * 1000) // 每60秒更新一次未读消息数
|
}, 60 * 1000) // 每60秒更新一次未读消息数
|
||||||
|
|
||||||
// 监听路由变化调整
|
// 监听路由变化调整
|
||||||
watch(() => route.path, (newPath) => {
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(newPath) => {
|
||||||
setActive(newPath)
|
setActive(newPath)
|
||||||
|
|
||||||
messageStore.updateUnreadCount()
|
messageStore.updateUnreadCount()
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const setActive = (path) => {
|
const setActive = (path) => {
|
||||||
active.value = (() => {
|
active.value = (() => {
|
||||||
@@ -142,10 +153,7 @@ const setActive = (path) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isShowAddBill = computed(() => {
|
const isShowAddBill = computed(() => {
|
||||||
return route.path === '/'
|
return route.path === '/' || route.path === '/balance' || route.path === '/message'
|
||||||
|| route.path === '/calendar'
|
|
||||||
|| route.path === '/balance'
|
|
||||||
|| route.path === '/message'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -166,7 +174,6 @@ const handleAddTransactionSuccess = () => {
|
|||||||
const event = new Event('transactions-changed')
|
const event = new Event('transactions-changed')
|
||||||
window.dispatchEvent(event)
|
window.dispatchEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export const uploadBillFile = (file, type) => {
|
|||||||
Authorization: `Bearer ${useAuthStore().token || ''}`
|
Authorization: `Bearer ${useAuthStore().token || ''}`
|
||||||
},
|
},
|
||||||
timeout: 60000 // 文件上传增加超时时间
|
timeout: 60000 // 文件上传增加超时时间
|
||||||
}).then(response => {
|
})
|
||||||
|
.then((response) => {
|
||||||
const { data } = response
|
const { data } = response
|
||||||
|
|
||||||
if (data.success === false) {
|
if (data.success === false) {
|
||||||
@@ -35,7 +36,8 @@ export const uploadBillFile = (file, type) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
console.error('上传错误:', error)
|
console.error('上传错误:', error)
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
|
|||||||
@@ -12,19 +12,6 @@ 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 预算数据
|
* @param {object} data 预算数据
|
||||||
@@ -84,15 +71,41 @@ export function getUncoveredCategories(category, referenceDate) {
|
|||||||
params: { category, referenceDate }
|
params: { category, referenceDate }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 归档预算
|
* 获取归档总结
|
||||||
* @param {number} year 年份
|
* @param {string} referenceDate 参考日期
|
||||||
* @param {number} month 月份
|
|
||||||
*/
|
*/
|
||||||
export function archiveBudgets(year, month) {
|
export function getArchiveSummary(referenceDate) {
|
||||||
return request({
|
return request({
|
||||||
url: `/Budget/ArchiveBudgetsAsync/${year}/${month}`,
|
url: '/Budget/GetArchiveSummary',
|
||||||
method: 'post'
|
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) {
|
||||||
|
return request({
|
||||||
|
url: '/Budget/GetSavingsBudget',
|
||||||
|
method: 'get',
|
||||||
|
params: { year, month, type }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const getEmailDetail = (id) => {
|
|||||||
*/
|
*/
|
||||||
export const deleteEmail = (id) => {
|
export const deleteEmail = (id) => {
|
||||||
return request({
|
return request({
|
||||||
url: `/EmailMessage/DeleteById`,
|
url: '/EmailMessage/DeleteById',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
params: { id }
|
params: { id }
|
||||||
})
|
})
|
||||||
@@ -50,7 +50,7 @@ export const deleteEmail = (id) => {
|
|||||||
*/
|
*/
|
||||||
export const refreshTransactionRecords = (id) => {
|
export const refreshTransactionRecords = (id) => {
|
||||||
return request({
|
return request({
|
||||||
url: `/EmailMessage/RefreshTransactionRecords`,
|
url: '/EmailMessage/RefreshTransactionRecords',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
params: { id }
|
params: { id }
|
||||||
})
|
})
|
||||||
@@ -62,7 +62,7 @@ export const refreshTransactionRecords = (id) => {
|
|||||||
*/
|
*/
|
||||||
export const syncEmails = () => {
|
export const syncEmails = () => {
|
||||||
return request({
|
return request({
|
||||||
url: `/EmailMessage/SyncEmails`,
|
url: '/EmailMessage/SyncEmails',
|
||||||
method: 'post'
|
method: 'post'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import request from '@/api/request'
|
import request from '@/api/request'
|
||||||
|
|
||||||
export function getJobs() {
|
export function getJobs () {
|
||||||
return request({
|
return request({
|
||||||
url: '/Job/GetJobs',
|
url: '/Job/GetJobs',
|
||||||
method: 'get'
|
method: 'get'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function executeJob(jobName) {
|
export function executeJob (jobName) {
|
||||||
return request({
|
return request({
|
||||||
url: '/Job/Execute',
|
url: '/Job/Execute',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
@@ -15,7 +15,7 @@ export function executeJob(jobName) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pauseJob(jobName) {
|
export function pauseJob (jobName) {
|
||||||
return request({
|
return request({
|
||||||
url: '/Job/Pause',
|
url: '/Job/Pause',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
@@ -23,7 +23,7 @@ export function pauseJob(jobName) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resumeJob(jobName) {
|
export function resumeJob (jobName) {
|
||||||
return request({
|
return request({
|
||||||
url: '/Job/Resume',
|
url: '/Job/Resume',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 日志相关 API
|
* 日志相关 API
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
* @param {string} [params.searchKeyword] - 搜索关键词
|
* @param {string} [params.searchKeyword] - 搜索关键词
|
||||||
* @param {string} [params.logLevel] - 日志级别
|
* @param {string} [params.logLevel] - 日志级别
|
||||||
* @param {string} [params.date] - 日期 (yyyyMMdd)
|
* @param {string} [params.date] - 日期 (yyyyMMdd)
|
||||||
|
* @param {string} [params.className] - 类名
|
||||||
* @returns {Promise<{success: boolean, data: Array, total: number}>}
|
* @returns {Promise<{success: boolean, data: Array, total: number}>}
|
||||||
*/
|
*/
|
||||||
export const getLogList = (params = {}) => {
|
export const getLogList = (params = {}) => {
|
||||||
@@ -32,3 +33,34 @@ export const getAvailableDates = () => {
|
|||||||
method: 'get'
|
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 { showToast } from 'vant'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
@@ -12,17 +12,31 @@ 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(
|
request.interceptors.request.use(
|
||||||
config => {
|
(config) => {
|
||||||
// 添加 token 认证信息
|
// 添加 token 认证信息
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
if (authStore.token) {
|
if (authStore.token) {
|
||||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加请求ID
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
config.headers['X-Request-ID'] = requestId
|
||||||
|
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
error => {
|
(error) => {
|
||||||
console.error('请求错误:', error)
|
console.error('请求错误:', error)
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
@@ -30,7 +44,7 @@ request.interceptors.request.use(
|
|||||||
|
|
||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
request.interceptors.response.use(
|
request.interceptors.response.use(
|
||||||
response => {
|
(response) => {
|
||||||
const { data } = response
|
const { data } = response
|
||||||
|
|
||||||
// 统一处理业务错误
|
// 统一处理业务错误
|
||||||
@@ -41,7 +55,7 @@ request.interceptors.response.use(
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
error => {
|
(error) => {
|
||||||
console.error('响应错误:', error)
|
console.error('响应错误:', error)
|
||||||
|
|
||||||
// 统一处理 HTTP 错误
|
// 统一处理 HTTP 错误
|
||||||
@@ -58,7 +72,10 @@ request.interceptors.response.use(
|
|||||||
// 清除登录状态并跳转到登录页
|
// 清除登录状态并跳转到登录页
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
|
router.push({
|
||||||
|
name: 'login',
|
||||||
|
query: { redirect: router.currentRoute.value.fullPath }
|
||||||
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 403:
|
case 403:
|
||||||
|
|||||||
@@ -17,8 +17,6 @@
|
|||||||
* @returns {Object} data.expenseCount - 支出笔数
|
* @returns {Object} data.expenseCount - 支出笔数
|
||||||
* @returns {Object} data.incomeCount - 收入笔数
|
* @returns {Object} data.incomeCount - 收入笔数
|
||||||
* @returns {Object} data.totalCount - 总笔数
|
* @returns {Object} data.totalCount - 总笔数
|
||||||
* @returns {Object} data.maxExpense - 最大单笔支出
|
|
||||||
* @returns {Object} data.maxIncome - 最大单笔收入
|
|
||||||
*/
|
*/
|
||||||
export const getMonthlyStatistics = (params) => {
|
export const getMonthlyStatistics = (params) => {
|
||||||
return request({
|
return request({
|
||||||
@@ -88,3 +86,36 @@ export const getDailyStatistics = (params) => {
|
|||||||
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) => {
|
export const deletePeriodic = (id) => {
|
||||||
return request({
|
return request({
|
||||||
url: `/TransactionPeriodic/DeleteById`,
|
url: '/TransactionPeriodic/DeleteById',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
params: { id }
|
params: { id }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const updateTransaction = (data) => {
|
|||||||
*/
|
*/
|
||||||
export const deleteTransaction = (id) => {
|
export const deleteTransaction = (id) => {
|
||||||
return request({
|
return request({
|
||||||
url: `/TransactionRecord/DeleteById`,
|
url: '/TransactionRecord/DeleteById',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
params: { id }
|
params: { id }
|
||||||
})
|
})
|
||||||
@@ -118,7 +118,6 @@ export const getTransactionsByDate = (date) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 注意:分类相关的API已迁移到 transactionCategory.js
|
// 注意:分类相关的API已迁移到 transactionCategory.js
|
||||||
// 请使用 getCategoryList 等新接口
|
// 请使用 getCategoryList 等新接口
|
||||||
|
|
||||||
@@ -160,7 +159,7 @@ export const smartClassify = (transactionIds = []) => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ transactionIds })
|
body: JSON.stringify({ transactionIds })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,55 +1,75 @@
|
|||||||
/* color palette from <https://github.com/vuejs/theme> */
|
/* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--vt-c-white: #ffffff;
|
--van-danger-color: rgb(255, 107, 107) !important; /* 覆盖默认的深红色 #ee0a24 */
|
||||||
--vt-c-white-soft: #f8f8f8;
|
--color-background: var(--van-background);
|
||||||
--vt-c-white-mute: #f2f2f2;
|
--color-background-soft: var(--van-background-2);
|
||||||
|
--color-background-mute: var(--van-gray-1);
|
||||||
|
|
||||||
--vt-c-black: #181818;
|
--color-border: var(--van-border-color);
|
||||||
--vt-c-black-soft: #222222;
|
--color-border-hover: var(--van-gray-5);
|
||||||
--vt-c-black-mute: #282828;
|
|
||||||
|
|
||||||
--vt-c-indigo: #2c3e50;
|
--color-heading: var(--van-text-color);
|
||||||
|
--color-text: var(--van-text-color);
|
||||||
--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;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--color-background: var(--vt-c-black);
|
--chart-axis: #333;
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
--chart-split: #333;
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
--chart-text-muted: #666;
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-dark-2);
|
/* Heatmap Colors - Dark Mode (GitHub Style) */
|
||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
--heatmap-level-0: var(--van-gray-2);
|
||||||
|
--heatmap-level-1: #9be9a8;
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
--heatmap-level-2: #40c463;
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
--heatmap-level-3: #30a14e;
|
||||||
|
--heatmap-level-4: #216e39;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Removed manual dark mode media query as Vant handles it */
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -60,14 +80,13 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: var(--color-text);
|
color: var(--van-text-color);
|
||||||
background: var(--color-background);
|
background: var(--van-background);
|
||||||
transition:
|
transition:
|
||||||
color 0.5s,
|
color 0.5s,
|
||||||
background-color 0.5s;
|
background-color 0.5s;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-family:
|
font-family:
|
||||||
Inter,
|
|
||||||
-apple-system,
|
-apple-system,
|
||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
'Segoe UI',
|
'Segoe UI',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
@import './base.css';
|
@import './base.css';
|
||||||
|
|
||||||
/* 禁用页面弹性缩放和橡皮筋效果 */
|
/* 禁用页面弹性缩放和橡皮筋效果 */
|
||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
overscroll-behavior-y: none;
|
overscroll-behavior-y: none;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -57,7 +58,9 @@ a,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #app {
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
show-cancel-button
|
show-cancel-button
|
||||||
@confirm="handleConfirm"
|
@confirm="handleConfirm"
|
||||||
>
|
>
|
||||||
<van-field v-model="classifyName" placeholder="请输入新的交易分类" />
|
<van-field
|
||||||
|
v-model="classifyName"
|
||||||
|
placeholder="请输入新的交易分类"
|
||||||
|
/>
|
||||||
</van-dialog>
|
</van-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
<van-field label="时间">
|
<van-field label="时间">
|
||||||
<template #input>
|
<template #input>
|
||||||
<div style="display: flex; gap: 16px">
|
<div style="display: flex; gap: 16px">
|
||||||
<div @click="showDatePicker = true">{{ form.date }}</div>
|
<div @click="showDatePicker = true">
|
||||||
<div @click="showTimePicker = true">{{ form.time }}</div>
|
{{ form.date }}
|
||||||
|
</div>
|
||||||
|
<div @click="showTimePicker = true">
|
||||||
|
{{ form.time }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
@@ -37,9 +41,9 @@
|
|||||||
<van-field name="type" label="类型">
|
<van-field name="type" label="类型">
|
||||||
<template #input>
|
<template #input>
|
||||||
<van-radio-group v-model="form.type" direction="horizontal" @change="handleTypeChange">
|
<van-radio-group v-model="form.type" direction="horizontal" @change="handleTypeChange">
|
||||||
<van-radio :name="0">支出</van-radio>
|
<van-radio :name="0"> 支出 </van-radio>
|
||||||
<van-radio :name="1">收入</van-radio>
|
<van-radio :name="1"> 收入 </van-radio>
|
||||||
<van-radio :name="2">不计</van-radio>
|
<van-radio :name="2"> 不计 </van-radio>
|
||||||
</van-radio-group>
|
</van-radio-group>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
@@ -47,23 +51,20 @@
|
|||||||
<!-- 分类 -->
|
<!-- 分类 -->
|
||||||
<van-field name="category" label="分类">
|
<van-field name="category" label="分类">
|
||||||
<template #input>
|
<template #input>
|
||||||
<span v-if="!categoryName" style="color: #c8c9cc;">请选择分类</span>
|
<span v-if="!categoryName" style="color: var(--van-text-color-3)">请选择分类</span>
|
||||||
<span v-else>{{ categoryName }}</span>
|
<span v-else>{{ categoryName }}</span>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
|
|
||||||
<!-- 分类选择组件 -->
|
<!-- 分类选择组件 -->
|
||||||
<ClassifySelector
|
<ClassifySelector v-model="categoryName" :type="form.type" />
|
||||||
v-model="categoryName"
|
|
||||||
:type="form.type"
|
|
||||||
/>
|
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<van-button round block type="primary" native-type="submit" :loading="loading">
|
<van-button round block type="primary" native-type="submit" :loading="loading">
|
||||||
{{ submitText }}
|
{{ submitText }}
|
||||||
</van-button>
|
</van-button>
|
||||||
<slot name="actions"></slot>
|
<slot name="actions" />
|
||||||
</div>
|
</div>
|
||||||
</van-form>
|
</van-form>
|
||||||
|
|
||||||
@@ -146,9 +147,15 @@ const initForm = async () => {
|
|||||||
currentTime.value = form.value.time.split(':')
|
currentTime.value = form.value.time.split(':')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amount !== undefined) form.value.amount = amount
|
if (amount !== undefined) {
|
||||||
if (reason !== undefined) form.value.note = reason
|
form.value.amount = amount
|
||||||
if (type !== undefined) form.value.type = type
|
}
|
||||||
|
if (reason !== undefined) {
|
||||||
|
form.value.note = reason
|
||||||
|
}
|
||||||
|
if (type !== undefined) {
|
||||||
|
form.value.type = type
|
||||||
|
}
|
||||||
|
|
||||||
// 如果有传入分类名称,尝试设置
|
// 如果有传入分类名称,尝试设置
|
||||||
if (classify) {
|
if (classify) {
|
||||||
@@ -166,9 +173,13 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 监听 initialData 变化 (例如重新解析后)
|
// 监听 initialData 变化 (例如重新解析后)
|
||||||
watch(() => props.initialData, () => {
|
watch(
|
||||||
|
() => props.initialData,
|
||||||
|
() => {
|
||||||
initForm()
|
initForm()
|
||||||
}, { deep: true })
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
const handleTypeChange = (newType) => {
|
const handleTypeChange = (newType) => {
|
||||||
if (!isSyncing.value) {
|
if (!isSyncing.value) {
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="manual-bill-add">
|
<div class="manual-bill-add">
|
||||||
<BillForm
|
<BillForm ref="billFormRef" :loading="saving" @submit="handleSave" />
|
||||||
ref="billFormRef"
|
|
||||||
:loading="saving"
|
|
||||||
@submit="handleSave"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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
|
<van-field
|
||||||
v-model="text"
|
v-model="text"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -31,13 +31,7 @@
|
|||||||
@submit="handleSave"
|
@submit="handleSave"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<van-button
|
<van-button plain round block class="mt-2" @click="parseResult = null">
|
||||||
plain
|
|
||||||
round
|
|
||||||
block
|
|
||||||
class="mt-2"
|
|
||||||
@click="parseResult = null"
|
|
||||||
>
|
|
||||||
重新输入
|
重新输入
|
||||||
</van-button>
|
</van-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,14 +54,16 @@ const saving = ref(false)
|
|||||||
const parseResult = ref(null)
|
const parseResult = ref(null)
|
||||||
|
|
||||||
const handleParse = async () => {
|
const handleParse = async () => {
|
||||||
if (!text.value.trim()) return
|
if (!text.value.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
parsing.value = true
|
parsing.value = true
|
||||||
parseResult.value = null
|
parseResult.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await parseOneLine(text.value)
|
const res = await parseOneLine(text.value)
|
||||||
if(!res.success){
|
if (!res.success) {
|
||||||
throw new Error(res.message || '解析失败')
|
throw new Error(res.message || '解析失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +117,6 @@ const handleSave = async (payload) => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #ebedf0;
|
border: 1px solid var(--van-border-color);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
<template>
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<div class="common-card budget-card" @click="toggleExpand">
|
<template>
|
||||||
|
<!-- 普通预算卡片 -->
|
||||||
|
<div
|
||||||
|
v-if="!budget.noLimit"
|
||||||
|
class="common-card budget-card"
|
||||||
|
:class="{ 'cursor-default': budget.category === 2 }"
|
||||||
|
@click="toggleExpand"
|
||||||
|
>
|
||||||
<div class="budget-content-wrapper">
|
<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="collapsed-header">
|
||||||
<div class="budget-info">
|
<div class="budget-info">
|
||||||
<slot name="tag">
|
<slot name="tag">
|
||||||
@@ -12,14 +22,26 @@
|
|||||||
class="status-tag"
|
class="status-tag"
|
||||||
>
|
>
|
||||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||||
|
<span
|
||||||
|
v-if="budget.isMandatoryExpense"
|
||||||
|
class="mandatory-mark"
|
||||||
|
>📌</span>
|
||||||
</van-tag>
|
</van-tag>
|
||||||
</slot>
|
</slot>
|
||||||
<h3 class="card-title">{{ budget.name }}</h3>
|
<h3 class="card-title">
|
||||||
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
|
{{ budget.name }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
v-if="budget.selectedCategories?.length"
|
||||||
|
class="card-subtitle"
|
||||||
|
>
|
||||||
({{ budget.selectedCategories.join('、') }})
|
({{ budget.selectedCategories.join('、') }})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<van-icon name="arrow-down" class="expand-icon" />
|
<van-icon
|
||||||
|
name="arrow-down"
|
||||||
|
class="expand-icon"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="collapsed-footer">
|
<div class="collapsed-footer">
|
||||||
@@ -27,24 +49,34 @@
|
|||||||
<span class="compact-label">实际/目标</span>
|
<span class="compact-label">实际/目标</span>
|
||||||
<span class="compact-value">
|
<span class="compact-value">
|
||||||
<slot name="collapsed-amount">
|
<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}`
|
? `¥${budget.current?.toFixed(0) || 0} / ¥${budget.limit?.toFixed(0) || 0}`
|
||||||
: '--' }}
|
: '--'
|
||||||
|
}}
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="collapsed-item">
|
<div class="collapsed-item">
|
||||||
<span class="compact-label">达成率</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 展开状态 -->
|
<!-- 展开状态 -->
|
||||||
<Transition v-else :name="transitionName">
|
<div
|
||||||
<div :key="budget.period" class="budget-inner-card">
|
v-else
|
||||||
<div class="card-header" style="margin-bottom: 0;">
|
class="budget-inner-card"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="card-header"
|
||||||
|
style="margin-bottom: 0"
|
||||||
|
>
|
||||||
<div class="budget-info">
|
<div class="budget-info">
|
||||||
<slot name="tag">
|
<slot name="tag">
|
||||||
<van-tag
|
<van-tag
|
||||||
@@ -53,9 +85,18 @@
|
|||||||
class="status-tag"
|
class="status-tag"
|
||||||
>
|
>
|
||||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||||
|
<span
|
||||||
|
v-if="budget.isMandatoryExpense"
|
||||||
|
class="mandatory-mark"
|
||||||
|
>📌</span>
|
||||||
</van-tag>
|
</van-tag>
|
||||||
</slot>
|
</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>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
@@ -82,13 +123,15 @@
|
|||||||
@click.stop="$emit('click', budget)"
|
@click.stop="$emit('click', budget)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="budget-body">
|
<div class="budget-body">
|
||||||
<div v-if="budget.selectedCategories?.length" class="category-tags">
|
<div
|
||||||
|
v-if="budget.selectedCategories?.length"
|
||||||
|
class="category-tags"
|
||||||
|
>
|
||||||
<van-tag
|
<van-tag
|
||||||
v-for="cat in budget.selectedCategories"
|
v-for="cat in budget.selectedCategories"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
@@ -101,7 +144,7 @@
|
|||||||
</van-tag>
|
</van-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="amount-info">
|
<div class="amount-info">
|
||||||
<slot name="amount-info"></slot>
|
<slot name="amount-info" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress-section">
|
<div class="progress-section">
|
||||||
@@ -113,7 +156,10 @@
|
|||||||
:color="progressColor"
|
:color="progressColor"
|
||||||
:show-pivot="false"
|
:show-pivot="false"
|
||||||
/>
|
/>
|
||||||
<span class="percent" :class="percentClass">{{ percentage }}%</span>
|
<span
|
||||||
|
class="percent"
|
||||||
|
:class="percentClass"
|
||||||
|
>{{ percentage }}%</span>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-section time-progress">
|
<div class="progress-section time-progress">
|
||||||
@@ -121,43 +167,224 @@
|
|||||||
<van-progress
|
<van-progress
|
||||||
:percentage="timePercentage"
|
:percentage="timePercentage"
|
||||||
stroke-width="4"
|
stroke-width="4"
|
||||||
color="#969799"
|
color="var(--van-gray-6)"
|
||||||
:show-pivot="false"
|
:show-pivot="false"
|
||||||
/>
|
/>
|
||||||
<span class="percent">{{ timePercentage }}%</span>
|
<span class="percent">{{ timePercentage }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-collapse-transition>
|
<transition
|
||||||
<div v-if="budget.description && showDescription" class="budget-description">
|
name="collapse"
|
||||||
<div class="description-content rich-html-content" v-html="budget.description"></div>
|
@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"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</van-collapse-transition>
|
</div>
|
||||||
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="period-navigation" @click.stop>
|
<slot name="footer" />
|
||||||
<van-button
|
</div>
|
||||||
icon="arrow-left"
|
</div>
|
||||||
class="nav-icon"
|
</div>
|
||||||
plain
|
|
||||||
size="small"
|
<!-- 关联账单列表弹窗 -->
|
||||||
style="width: 50px;"
|
<PopupContainer
|
||||||
@click="handleSwitch(-1)"
|
v-model="showBillListModal"
|
||||||
|
title="关联账单列表"
|
||||||
|
height="75%"
|
||||||
|
>
|
||||||
|
<TransactionList
|
||||||
|
:transactions="billList"
|
||||||
|
:loading="billLoading"
|
||||||
|
:finished="true"
|
||||||
|
:show-delete="false"
|
||||||
|
:show-checkbox="false"
|
||||||
|
@click="handleBillClick"
|
||||||
|
@delete="handleBillDelete"
|
||||||
/>
|
/>
|
||||||
<span class="period-text">{{ budget.period }}</span>
|
</PopupContainer>
|
||||||
<van-button
|
</div>
|
||||||
icon="arrow"
|
|
||||||
class="nav-icon"
|
<!-- 不记额预算卡片 -->
|
||||||
|
<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
|
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">
|
||||||
|
<van-button
|
||||||
|
v-if="budget.description"
|
||||||
|
:icon="showDescription ? 'info' : 'info-o'"
|
||||||
size="small"
|
size="small"
|
||||||
style="width: 50px;"
|
:type="showDescription ? 'primary' : 'default'"
|
||||||
:disabled="isNextDisabled"
|
plain
|
||||||
@click="handleSwitch(1)"
|
@click.stop="showDescription = !showDescription"
|
||||||
|
/>
|
||||||
|
<van-button
|
||||||
|
icon="orders-o"
|
||||||
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 关联账单列表弹窗 -->
|
<!-- 关联账单列表弹窗 -->
|
||||||
@@ -193,7 +420,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
progressColor: {
|
progressColor: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '#1989fa'
|
default: 'var(--van-primary-color)'
|
||||||
},
|
},
|
||||||
percentClass: {
|
percentClass: {
|
||||||
type: [String, Object],
|
type: [String, Object],
|
||||||
@@ -205,29 +432,22 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['switch-period', 'click'])
|
const emit = defineEmits(['click'])
|
||||||
|
|
||||||
const isExpanded = ref(props.budget.category === 2)
|
const isExpanded = ref(props.budget.category === 2)
|
||||||
const transitionName = ref('slide-left')
|
|
||||||
const showDescription = ref(false)
|
const showDescription = ref(false)
|
||||||
const showBillListModal = ref(false)
|
const showBillListModal = ref(false)
|
||||||
const billList = ref([])
|
const billList = ref([])
|
||||||
const billLoading = ref(false)
|
const billLoading = ref(false)
|
||||||
|
|
||||||
const toggleExpand = () => {
|
const toggleExpand = () => {
|
||||||
|
// 存款类型(category === 2)强制保持展开状态,不可折叠
|
||||||
|
if (props.budget.category === 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
isExpanded.value = !isExpanded.value
|
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 () => {
|
const handleQueryBills = async () => {
|
||||||
showBillListModal.value = true
|
showBillListModal.value = true
|
||||||
billLoading.value = true
|
billLoading.value = true
|
||||||
@@ -254,12 +474,11 @@ const handleQueryBills = async () => {
|
|||||||
sortByAmount: true
|
sortByAmount: true
|
||||||
})
|
})
|
||||||
|
|
||||||
if(response.success) {
|
if (response.success) {
|
||||||
billList.value = response.data || []
|
billList.value = response.data || []
|
||||||
} else {
|
} else {
|
||||||
billList.value = []
|
billList.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('查询账单列表失败:', error)
|
console.error('查询账单列表失败:', error)
|
||||||
billList.value = []
|
billList.value = []
|
||||||
@@ -269,21 +488,59 @@ const handleQueryBills = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const percentage = computed(() => {
|
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)
|
return Math.round((props.budget.current / props.budget.limit) * 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
const timePercentage = computed(() => {
|
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 start = new Date(props.budget.periodStart).getTime()
|
||||||
const end = new Date(props.budget.periodEnd).getTime()
|
const end = new Date(props.budget.periodEnd).getTime()
|
||||||
const now = new Date().getTime()
|
const now = new Date().getTime()
|
||||||
|
|
||||||
if (now <= start) return 0
|
if (now <= start) {
|
||||||
if (now >= end) return 100
|
return 0
|
||||||
|
}
|
||||||
|
if (now >= end) {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
|
||||||
return Math.round(((now - start) / (end - start)) * 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -297,6 +554,18 @@ const timePercentage = computed(() => {
|
|||||||
cursor: pointer;
|
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 {
|
.budget-content-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -368,7 +637,7 @@ const timePercentage = computed(() => {
|
|||||||
|
|
||||||
.compact-label {
|
.compact-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,60 +651,26 @@ const timePercentage = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compact-value.warning {
|
.compact-value.warning {
|
||||||
color: #ff976a;
|
color: var(--van-warning-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-value.income {
|
.compact-value.income {
|
||||||
color: #07c160;
|
color: var(--van-success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-icon {
|
.expand-icon {
|
||||||
color: #1989fa;
|
color: var(--van-primary-color);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-icon {
|
.collapse-icon {
|
||||||
color: #1989fa;
|
color: var(--van-primary-color);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
cursor: pointer;
|
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 {
|
.budget-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -456,7 +691,7 @@ const timePercentage = computed(() => {
|
|||||||
|
|
||||||
.card-subtitle {
|
.card-subtitle {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -490,7 +725,7 @@ const timePercentage = computed(() => {
|
|||||||
|
|
||||||
:deep(.info-item) .label {
|
:deep(.info-item) .label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,11 +735,11 @@ const timePercentage = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.value.expense) {
|
:deep(.value.expense) {
|
||||||
color: #ee0a24;
|
color: var(--van-danger-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.value.income) {
|
:deep(.value.income) {
|
||||||
color: #07c160;
|
color: var(--van-success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-section {
|
.progress-section {
|
||||||
@@ -513,7 +748,7 @@ const timePercentage = computed(() => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #646566;
|
color: var(--van-gray-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-section :deep(.van-progress) {
|
.progress-section :deep(.van-progress) {
|
||||||
@@ -532,12 +767,12 @@ const timePercentage = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.percent.warning {
|
.percent.warning {
|
||||||
color: #ff976a;
|
color: var(--van-warning-color);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.percent.income {
|
.percent.income {
|
||||||
color: #07c160;
|
color: var(--van-success-color);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,63 +786,58 @@ const timePercentage = computed(() => {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-description {
|
.no-limit-notice {
|
||||||
margin-top: 8px;
|
text-align: center;
|
||||||
background-color: #f7f8fa;
|
font-size: 12px;
|
||||||
|
color: var(--van-text-color-2);
|
||||||
|
background-color: var(--van-light-gray);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description-content {
|
.description-content {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #646566;
|
color: var(--van-gray-6);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-footer {
|
.mandatory-mark {
|
||||||
display: flex;
|
margin-left: 4px;
|
||||||
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-size: 14px;
|
||||||
font-weight: 500;
|
display: inline-block;
|
||||||
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>
|
</style>
|
||||||
|
|||||||
1062
Web/src/components/Budget/BudgetChartAnalysis.vue
Normal file
1062
Web/src/components/Budget/BudgetChartAnalysis.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<PopupContainer
|
<PopupContainer
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
:title="isEdit ? `编辑${getCategoryName(form.category)}预算` : `新增${getCategoryName(form.category)}预算`"
|
:title="
|
||||||
|
isEdit
|
||||||
|
? `编辑${getCategoryName(form.category)}预算`
|
||||||
|
: `新增${getCategoryName(form.category)}预算`
|
||||||
|
"
|
||||||
height="75%"
|
height="75%"
|
||||||
>
|
>
|
||||||
<div class="add-budget-form">
|
<div class="add-budget-form">
|
||||||
@@ -14,19 +18,55 @@
|
|||||||
placeholder="例如:每月餐饮、年度奖金"
|
placeholder="例如:每月餐饮、年度奖金"
|
||||||
:rules="[{ required: true, message: '请填写预算名称' }]"
|
:rules="[{ required: true, message: '请填写预算名称' }]"
|
||||||
/>
|
/>
|
||||||
<van-field name="type" label="统计周期">
|
<!-- 新增:不记额预算复选框 -->
|
||||||
|
<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="统计周期"
|
||||||
|
>
|
||||||
<template #input>
|
<template #input>
|
||||||
<van-radio-group
|
<van-radio-group
|
||||||
v-model="form.type"
|
v-model="form.type"
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
:disabled="isEdit"
|
:disabled="isEdit || form.noLimit"
|
||||||
>
|
>
|
||||||
<van-radio :name="BudgetPeriodType.Month">月</van-radio>
|
<van-radio :name="BudgetPeriodType.Month">
|
||||||
<van-radio :name="BudgetPeriodType.Year">年</van-radio>
|
月
|
||||||
|
</van-radio>
|
||||||
|
<van-radio :name="BudgetPeriodType.Year">
|
||||||
|
年
|
||||||
|
</van-radio>
|
||||||
</van-radio-group>
|
</van-radio-group>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
|
<!-- 仅当未选中"不记额预算"时显示预算金额 -->
|
||||||
<van-field
|
<van-field
|
||||||
|
v-if="!form.noLimit"
|
||||||
v-model="form.limit"
|
v-model="form.limit"
|
||||||
type="number"
|
type="number"
|
||||||
name="limit"
|
name="limit"
|
||||||
@@ -40,8 +80,16 @@
|
|||||||
</van-field>
|
</van-field>
|
||||||
<van-field label="相关分类">
|
<van-field label="相关分类">
|
||||||
<template #input>
|
<template #input>
|
||||||
<div v-if="form.selectedCategories.length === 0" style="color: #c8c9cc;">可多选分类</div>
|
<div
|
||||||
<div v-else class="selected-categories">
|
v-if="form.selectedCategories.length === 0"
|
||||||
|
style="color: var(--van-text-color-3)"
|
||||||
|
>
|
||||||
|
可多选分类
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="selected-categories"
|
||||||
|
>
|
||||||
<span class="ellipsis-text">
|
<span class="ellipsis-text">
|
||||||
{{ form.selectedCategories.join('、') }}
|
{{ form.selectedCategories.join('、') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -59,7 +107,14 @@
|
|||||||
</van-form>
|
</van-form>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<van-button block round type="primary" @click="onSubmit">保存预算</van-button>
|
<van-button
|
||||||
|
block
|
||||||
|
round
|
||||||
|
type="primary"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
保存预算
|
||||||
|
</van-button>
|
||||||
</template>
|
</template>
|
||||||
</PopupContainer>
|
</PopupContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -83,15 +138,13 @@ const form = reactive({
|
|||||||
type: BudgetPeriodType.Month,
|
type: BudgetPeriodType.Month,
|
||||||
category: BudgetCategory.Expense,
|
category: BudgetCategory.Expense,
|
||||||
limit: '',
|
limit: '',
|
||||||
selectedCategories: []
|
selectedCategories: [],
|
||||||
|
noLimit: false, // 新增字段
|
||||||
|
isMandatoryExpense: false // 新增:硬性消费
|
||||||
})
|
})
|
||||||
|
|
||||||
const open = ({
|
const open = ({ data, isEditFlag, category }) => {
|
||||||
data,
|
if (category === undefined) {
|
||||||
isEditFlag,
|
|
||||||
category
|
|
||||||
}) => {
|
|
||||||
if(category === undefined) {
|
|
||||||
showToast('缺少必要参数:category')
|
showToast('缺少必要参数:category')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -104,7 +157,9 @@ const open = ({
|
|||||||
type: data.type,
|
type: data.type,
|
||||||
category: category,
|
category: category,
|
||||||
limit: data.limit,
|
limit: data.limit,
|
||||||
selectedCategories: data.selectedCategories ? [...data.selectedCategories] : []
|
selectedCategories: data.selectedCategories ? [...data.selectedCategories] : [],
|
||||||
|
noLimit: data.noLimit || false, // 新增
|
||||||
|
isMandatoryExpense: data.isMandatoryExpense || false // 新增:硬性消费
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
@@ -113,7 +168,9 @@ const open = ({
|
|||||||
type: BudgetPeriodType.Month,
|
type: BudgetPeriodType.Month,
|
||||||
category: category,
|
category: category,
|
||||||
limit: '',
|
limit: '',
|
||||||
selectedCategories: []
|
selectedCategories: [],
|
||||||
|
noLimit: false, // 新增
|
||||||
|
isMandatoryExpense: false // 新增:硬性消费
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -124,15 +181,21 @@ defineExpose({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const budgetType = computed(() => {
|
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 () => {
|
const onSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
...form,
|
...form,
|
||||||
limit: parseFloat(form.limit),
|
limit: form.noLimit ? 0 : parseFloat(form.limit), // 不记额时金额为0
|
||||||
selectedCategories: form.selectedCategories
|
selectedCategories: form.selectedCategories,
|
||||||
|
noLimit: form.noLimit, // 新增
|
||||||
|
isMandatoryExpense: form.isMandatoryExpense // 新增:硬性消费
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = form.id ? await updateBudget(data) : await createBudget(data)
|
const res = form.id ? await updateBudget(data) : await createBudget(data)
|
||||||
@@ -148,7 +211,7 @@ const onSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getCategoryName = (category) => {
|
const getCategoryName = (category) => {
|
||||||
switch(category) {
|
switch (category) {
|
||||||
case BudgetCategory.Expense:
|
case BudgetCategory.Expense:
|
||||||
return '支出'
|
return '支出'
|
||||||
case BudgetCategory.Income:
|
case BudgetCategory.Income:
|
||||||
@@ -159,6 +222,15 @@ const getCategoryName = (category) => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onNoLimitChange = (value) => {
|
||||||
|
if (value) {
|
||||||
|
// 选中不记额时,自动设为年度预算
|
||||||
|
form.type = BudgetPeriodType.Year
|
||||||
|
// 选中不记额时,清除硬性消费选择
|
||||||
|
form.isMandatoryExpense = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -176,7 +248,7 @@ const getCategoryName = (category) => {
|
|||||||
|
|
||||||
.ellipsis-text {
|
.ellipsis-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #323233;
|
color: var(--van-text-color);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -185,7 +257,19 @@ const getCategoryName = (category) => {
|
|||||||
|
|
||||||
.no-data {
|
.no-data {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
padding: 8px 16px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="summary-container">
|
<div class="summary-container">
|
||||||
<transition :name="transitionName" mode="out-in">
|
<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)">
|
<div class="nav-arrow left" @click.stop="changeMonth(-1)">
|
||||||
<van-icon name="arrow-left" />
|
<van-icon name="arrow-left" />
|
||||||
@@ -20,7 +24,7 @@
|
|||||||
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
|
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="config.showDivider" class="divider"></div>
|
<div v-if="config.showDivider" class="divider" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,8 +75,7 @@ const dateKey = computed(() => props.date.getFullYear() + '-' + props.date.getMo
|
|||||||
|
|
||||||
const isCurrentMonth = computed(() => {
|
const isCurrentMonth = computed(() => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
return props.date.getFullYear() === now.getFullYear() &&
|
return props.date.getFullYear() === now.getFullYear() && props.date.getMonth() === now.getMonth()
|
||||||
props.date.getMonth() === now.getMonth()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const periodConfigs = computed(() => ({
|
const periodConfigs = computed(() => ({
|
||||||
@@ -94,7 +97,10 @@ const changeMonth = (delta) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatMoney = (val) => {
|
const formatMoney = (val) => {
|
||||||
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
return parseFloat(val || 0).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -130,7 +136,7 @@ const formatMoney = (val) => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #c8c9cc;
|
color: var(--van-gray-5);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -141,6 +147,17 @@ const formatMoney = (val) => {
|
|||||||
background-color: rgba(0, 0, 0, 0.02);
|
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 {
|
.nav-arrow.left {
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
@@ -150,7 +167,7 @@ const formatMoney = (val) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-arrow.disabled {
|
.nav-arrow.disabled {
|
||||||
color: #f2f3f5;
|
color: var(--van-gray-3);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,26 +217,26 @@ const formatMoney = (val) => {
|
|||||||
|
|
||||||
.summary-item .label {
|
.summary-item .label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-item .value {
|
.summary-item .value {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #323233;
|
color: var(--van-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-item :deep(.value.expense) {
|
.summary-item :deep(.value.expense) {
|
||||||
color: #ee0a24;
|
color: var(--van-danger-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-item :deep(.value.income) {
|
.summary-item :deep(.value.income) {
|
||||||
color: #07c160;
|
color: var(--van-success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-item :deep(.value.warning) {
|
.summary-item :deep(.value.warning) {
|
||||||
color: #ff976a;
|
color: var(--van-warning-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-item .unit {
|
.summary-item .unit {
|
||||||
@@ -230,7 +247,7 @@ const formatMoney = (val) => {
|
|||||||
|
|
||||||
.summary-item .sub-info {
|
.summary-item .sub-info {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #c8c9cc;
|
color: var(--van-text-color-3);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -238,35 +255,35 @@ const formatMoney = (val) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-item .amount {
|
.summary-item .amount {
|
||||||
color: #646566;
|
color: var(--van-text-color-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-item .separator {
|
.summary-item .separator {
|
||||||
color: #c8c9cc;
|
color: var(--van-text-color-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background-color: #ebedf0;
|
background-color: var(--van-border-color);
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
.nav-arrow:active {
|
.nav-arrow:active {
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
.nav-arrow.disabled {
|
.nav-arrow.disabled {
|
||||||
color: #323233;
|
color: var(--van-text-color);
|
||||||
}
|
}
|
||||||
.summary-item .value {
|
.summary-item .value {
|
||||||
color: #f5f5f5;
|
color: var(--van-text-color);
|
||||||
}
|
}
|
||||||
.summary-item .amount {
|
.summary-item .amount {
|
||||||
color: #c8c9cc;
|
color: var(--van-text-color-3);
|
||||||
}
|
}
|
||||||
.divider {
|
.divider {
|
||||||
background-color: #2c2c2c;
|
background-color: var(--van-border-color);
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<PopupContainer
|
<PopupContainer v-model="visible" title="设置存款分类" height="60%">
|
||||||
v-model="visible"
|
|
||||||
title="设置存款分类"
|
|
||||||
height="60%"
|
|
||||||
>
|
|
||||||
<div class="savings-config-content">
|
<div class="savings-config-content">
|
||||||
<div class="config-header">
|
<div class="config-header">
|
||||||
<p class="subtitle">这些分类的统计值将计入“存款”中</p>
|
<p class="subtitle">这些分类的统计值将计入“存款”中</p>
|
||||||
@@ -22,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<van-button block round type="primary" @click="onSubmit">保存配置</van-button>
|
<van-button block round type="primary" @click="onSubmit"> 保存配置 </van-button>
|
||||||
</template>
|
</template>
|
||||||
</PopupContainer>
|
</PopupContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -52,7 +48,7 @@ const fetchConfig = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getConfig('SavingsCategories')
|
const res = await getConfig('SavingsCategories')
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
selectedCategories.value = res.data.split(',').filter(x => x)
|
selectedCategories.value = res.data.split(',').filter((x) => x)
|
||||||
} else {
|
} else {
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
}
|
}
|
||||||
@@ -91,7 +87,7 @@ const onSubmit = async () => {
|
|||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +99,7 @@ const onSubmit = async () => {
|
|||||||
|
|
||||||
.no-data {
|
.no-data {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,17 +98,21 @@ const innerOptions = ref([])
|
|||||||
const addClassifyDialogRef = ref()
|
const addClassifyDialogRef = ref()
|
||||||
|
|
||||||
const displayOptions = computed(() => {
|
const displayOptions = computed(() => {
|
||||||
if (props.options) return props.options
|
if (props.options) {
|
||||||
|
return props.options
|
||||||
|
}
|
||||||
return innerOptions.value
|
return innerOptions.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchOptions = async () => {
|
const fetchOptions = async () => {
|
||||||
if (props.options) return
|
if (props.options) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getCategoryList(props.type)
|
const response = await getCategoryList(props.type)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
innerOptions.value = (response.data || []).map(item => ({
|
innerOptions.value = (response.data || []).map((item) => ({
|
||||||
text: item.name,
|
text: item.name,
|
||||||
value: item.name,
|
value: item.name,
|
||||||
id: item.id
|
id: item.id
|
||||||
@@ -152,9 +156,12 @@ const handleAddConfirm = async (categoryName) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.type, () => {
|
watch(
|
||||||
|
() => props.type,
|
||||||
|
() => {
|
||||||
fetchOptions()
|
fetchOptions()
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchOptions()
|
fetchOptions()
|
||||||
@@ -175,8 +182,10 @@ const isSelected = (item) => {
|
|||||||
|
|
||||||
// 是否全部选中
|
// 是否全部选中
|
||||||
const isAllSelected = computed(() => {
|
const isAllSelected = computed(() => {
|
||||||
if (!props.multiple || displayOptions.value.length === 0) return false
|
if (!props.multiple || displayOptions.value.length === 0) {
|
||||||
return displayOptions.value.every(item => props.modelValue.includes(item.text))
|
return false
|
||||||
|
}
|
||||||
|
return displayOptions.value.every((item) => props.modelValue.includes(item.text))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 是否有任何选中
|
// 是否有任何选中
|
||||||
@@ -208,13 +217,15 @@ const toggleItem = (item) => {
|
|||||||
|
|
||||||
// 切换全选
|
// 切换全选
|
||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
if (!props.multiple) return
|
if (!props.multiple) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isAllSelected.value) {
|
if (isAllSelected.value) {
|
||||||
emit('update:modelValue', [])
|
emit('update:modelValue', [])
|
||||||
emit('change', [])
|
emit('change', [])
|
||||||
} else {
|
} else {
|
||||||
const allValues = displayOptions.value.map(item => item.text)
|
const allValues = displayOptions.value.map((item) => item.text)
|
||||||
emit('update:modelValue', allValues)
|
emit('update:modelValue', allValues)
|
||||||
emit('change', allValues)
|
emit('change', allValues)
|
||||||
}
|
}
|
||||||
|
|||||||
446
Web/src/components/ContributionHeatmap.vue
Normal file
446
Web/src/components/ContributionHeatmap.vue
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
<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,11 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Bill Modal -->
|
<!-- Add Bill Modal -->
|
||||||
<PopupContainer
|
<PopupContainer v-model="showAddBill" title="记一笔" height="75%">
|
||||||
v-model="showAddBill"
|
|
||||||
title="记一笔"
|
|
||||||
height="75%"
|
|
||||||
>
|
|
||||||
<van-tabs v-model:active="activeTab" shrink>
|
<van-tabs v-model:active="activeTab" shrink>
|
||||||
<van-tab title="一句话录账" name="one">
|
<van-tab title="一句话录账" name="one">
|
||||||
<OneLineBillAdd :key="componentKey" @success="handleSuccess" />
|
<OneLineBillAdd :key="componentKey" @success="handleSuccess" />
|
||||||
@@ -79,6 +75,6 @@ const handleSuccess = () => {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background-color: #fff;
|
background-color: var(--van-background-2);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,10 +13,12 @@
|
|||||||
<div class="popup-header-fixed">
|
<div class="popup-header-fixed">
|
||||||
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
|
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
|
||||||
<div class="header-title-row" :class="{ 'has-actions': !subtitle && hasActions }">
|
<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">
|
<div v-if="!subtitle && hasActions" class="header-actions-inline">
|
||||||
<slot name="header-actions"></slot>
|
<slot name="header-actions" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,18 +26,18 @@
|
|||||||
<div v-if="subtitle" class="header-stats">
|
<div v-if="subtitle" class="header-stats">
|
||||||
<span class="stats-text" v-html="subtitle" />
|
<span class="stats-text" v-html="subtitle" />
|
||||||
<!-- 额外操作插槽 -->
|
<!-- 额外操作插槽 -->
|
||||||
<slot v-if="hasActions" name="header-actions"></slot>
|
<slot v-if="hasActions" name="header-actions" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内容区域(可滚动) -->
|
<!-- 内容区域(可滚动) -->
|
||||||
<div class="popup-scroll-content">
|
<div class="popup-scroll-content">
|
||||||
<slot></slot>
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部页脚,固定不可滚动 -->
|
<!-- 底部页脚,固定不可滚动 -->
|
||||||
<div v-if="slots.footer" class="popup-footer-fixed">
|
<div v-if="slots.footer" class="popup-footer-fixed">
|
||||||
<slot name="footer"></slot>
|
<slot name="footer" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
@@ -47,24 +49,24 @@ import { computed, useSlots } from 'vue'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: ''
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: ''
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '80%',
|
default: '80%'
|
||||||
},
|
},
|
||||||
closeable: {
|
closeable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
@@ -74,7 +76,7 @@ const slots = useSlots()
|
|||||||
// 双向绑定
|
// 双向绑定
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value) => emit('update:modelValue', value),
|
set: (value) => emit('update:modelValue', value)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 判断是否有操作按钮
|
// 判断是否有操作按钮
|
||||||
@@ -91,8 +93,8 @@ const hasActions = computed(() => !!slots['header-actions'])
|
|||||||
.popup-header-fixed {
|
.popup-header-fixed {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background-color: var(--van-background-2, #f7f8fa);
|
background-color: var(--van-background-2);
|
||||||
border-bottom: 1px solid var(--van-border-color, #ebedf0);
|
border-bottom: 1px solid var(--van-border-color);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -121,7 +123,7 @@ const hasActions = computed(() => !!slots['header-actions'])
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--van-text-color, #323233);
|
color: var(--van-text-color);
|
||||||
/*超出长度*/
|
/*超出长度*/
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -139,7 +141,7 @@ const hasActions = computed(() => !!slots['header-actions'])
|
|||||||
.stats-text {
|
.stats-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--van-text-color-2, #646566);
|
color: var(--van-text-color-2);
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -159,8 +161,8 @@ const hasActions = computed(() => !!slots['header-actions'])
|
|||||||
|
|
||||||
.popup-footer-fixed {
|
.popup-footer-fixed {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-top: 1px solid var(--van-border-color, #ebedf0);
|
border-top: 1px solid var(--van-border-color);
|
||||||
background-color: var(--van-background-2, #f7f8fa);
|
background-color: var(--van-background-2);
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="reason-group-list-v2">
|
<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
|
<van-cell
|
||||||
v-for="group in groups"
|
v-for="group in groups"
|
||||||
:key="group.reason"
|
:key="group.reason"
|
||||||
@@ -27,7 +33,7 @@
|
|||||||
<van-tag
|
<van-tag
|
||||||
:type="getTypeColor(group.sampleType)"
|
:type="getTypeColor(group.sampleType)"
|
||||||
size="medium"
|
size="medium"
|
||||||
style="margin-right: 8px;"
|
style="margin-right: 8px"
|
||||||
>
|
>
|
||||||
{{ getTypeName(group.sampleType) }}
|
{{ getTypeName(group.sampleType) }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
@@ -35,12 +41,15 @@
|
|||||||
v-if="group.sampleClassify"
|
v-if="group.sampleClassify"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="medium"
|
size="medium"
|
||||||
style="margin-right: 8px;"
|
style="margin-right: 8px"
|
||||||
>
|
>
|
||||||
{{ group.sampleClassify }}
|
{{ group.sampleClassify }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
<span class="count-text">{{ group.count }} 条</span>
|
<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) }}
|
¥{{ Math.abs(group.totalAmount).toFixed(2) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +101,10 @@
|
|||||||
title="批量设置分类"
|
title="批量设置分类"
|
||||||
height="60%"
|
height="60%"
|
||||||
>
|
>
|
||||||
<van-form ref="batchFormRef" class="setting-form">
|
<van-form
|
||||||
|
ref="batchFormRef"
|
||||||
|
class="setting-form"
|
||||||
|
>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<!-- 显示选中的摘要 -->
|
<!-- 显示选中的摘要 -->
|
||||||
<van-field
|
<van-field
|
||||||
@@ -111,20 +123,38 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 交易类型 -->
|
<!-- 交易类型 -->
|
||||||
<van-field name="type" label="交易类型">
|
<van-field
|
||||||
|
name="type"
|
||||||
|
label="交易类型"
|
||||||
|
>
|
||||||
<template #input>
|
<template #input>
|
||||||
<van-radio-group v-model="batchForm.type" direction="horizontal">
|
<van-radio-group
|
||||||
<van-radio :name="0">支出</van-radio>
|
v-model="batchForm.type"
|
||||||
<van-radio :name="1">收入</van-radio>
|
direction="horizontal"
|
||||||
<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>
|
</van-radio-group>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
|
|
||||||
<!-- 分类选择 -->
|
<!-- 分类选择 -->
|
||||||
<van-field name="classify" label="分类">
|
<van-field
|
||||||
|
name="classify"
|
||||||
|
label="分类"
|
||||||
|
>
|
||||||
<template #input>
|
<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>
|
<span v-else>{{ batchForm.classify }}</span>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
@@ -152,13 +182,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import {
|
import { showToast, showSuccessToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
|
||||||
showToast,
|
|
||||||
showSuccessToast,
|
|
||||||
showLoadingToast,
|
|
||||||
closeToast,
|
|
||||||
showConfirmDialog
|
|
||||||
} from 'vant'
|
|
||||||
import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord'
|
import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord'
|
||||||
import ClassifySelector from './ClassifySelector.vue'
|
import ClassifySelector from './ClassifySelector.vue'
|
||||||
import TransactionList from './TransactionList.vue'
|
import TransactionList from './TransactionList.vue'
|
||||||
@@ -212,9 +236,12 @@ const batchForm = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 监听交易类型变化,重新加载分类
|
// 监听交易类型变化,重新加载分类
|
||||||
watch(() => batchForm.value.type, (newVal) => {
|
watch(
|
||||||
|
() => batchForm.value.type,
|
||||||
|
(newVal) => {
|
||||||
batchForm.value.classify = ''
|
batchForm.value.classify = ''
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 获取类型名称
|
// 获取类型名称
|
||||||
const getTypeName = (type) => {
|
const getTypeName = (type) => {
|
||||||
@@ -256,7 +283,9 @@ const handleGroupClick = async (group) => {
|
|||||||
|
|
||||||
// 加载分组的交易记录
|
// 加载分组的交易记录
|
||||||
const loadGroupTransactions = async () => {
|
const loadGroupTransactions = async () => {
|
||||||
if (transactionFinished.value || !selectedGroup.value) return
|
if (transactionFinished.value || !selectedGroup.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
transactionLoading.value = true
|
transactionLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -354,15 +383,13 @@ const handleConfirmBatchUpdate = async () => {
|
|||||||
emit('data-changed')
|
emit('data-changed')
|
||||||
try {
|
try {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(
|
new CustomEvent('transactions-changed', {
|
||||||
'transactions-changed',
|
|
||||||
{
|
|
||||||
detail: {
|
detail: {
|
||||||
reason: batchGroup.value.reason
|
reason: batchGroup.value.reason
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
} catch(e) {
|
} catch (e) {
|
||||||
console.error('触发全局 transactions-changed 事件失败:', e)
|
console.error('触发全局 transactions-changed 事件失败:', e)
|
||||||
}
|
}
|
||||||
// 关闭弹窗
|
// 关闭弹窗
|
||||||
@@ -398,18 +425,18 @@ const handleTransactionClick = (transaction) => {
|
|||||||
|
|
||||||
// 处理分组中的删除事件
|
// 处理分组中的删除事件
|
||||||
const handleGroupTransactionDelete = async (transactionId) => {
|
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)
|
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()
|
await loadGroupTransactions()
|
||||||
}
|
}
|
||||||
|
|
||||||
if(groupTransactions.value.length === 0){
|
if (groupTransactions.value.length === 0) {
|
||||||
// 如果删除后当前分组没有交易了,关闭弹窗
|
// 如果删除后当前分组没有交易了,关闭弹窗
|
||||||
showTransactionList.value = false
|
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
|
selectedGroup.value = null
|
||||||
total.value--
|
total.value--
|
||||||
}
|
}
|
||||||
@@ -433,10 +460,12 @@ const onGlobalTransactionDeleted = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
window.addEventListener &&
|
||||||
|
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
window.removeEventListener &&
|
||||||
|
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当有交易新增/修改/批量更新时的刷新监听
|
// 当有交易新增/修改/批量更新时的刷新监听
|
||||||
@@ -451,10 +480,12 @@ const onGlobalTransactionsChanged = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
|
window.addEventListener &&
|
||||||
|
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
window.removeEventListener &&
|
||||||
|
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 处理账单保存后的回调
|
// 处理账单保存后的回调
|
||||||
@@ -471,7 +502,9 @@ const handleTransactionSaved = async () => {
|
|||||||
* 加载数据(支持分页)
|
* 加载数据(支持分页)
|
||||||
*/
|
*/
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (finished.value) return
|
if (finished.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -522,7 +555,7 @@ const refresh = async () => {
|
|||||||
*/
|
*/
|
||||||
const getList = (onlySelected = false) => {
|
const getList = (onlySelected = false) => {
|
||||||
if (onlySelected && props.selectable) {
|
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]
|
return [...groups.value]
|
||||||
}
|
}
|
||||||
@@ -564,7 +597,7 @@ const clearSelection = () => {
|
|||||||
* 全选
|
* 全选
|
||||||
*/
|
*/
|
||||||
const selectAll = () => {
|
const selectAll = () => {
|
||||||
selectedReasons.value = new Set(groups.value.map(g => g.reason))
|
selectedReasons.value = new Set(groups.value.map((g) => g.reason))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
@@ -627,13 +660,13 @@ defineExpose({
|
|||||||
|
|
||||||
.count-text {
|
.count-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-text {
|
.amount-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #ff976a;
|
color: var(--van-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.van-cell-group--inset) {
|
:deep(.van-cell-group--inset) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
>
|
>
|
||||||
<template v-if="!loading && !saving">
|
<template v-if="!loading && !saving">
|
||||||
<van-icon :name="buttonIcon" />
|
<van-icon :name="buttonIcon" />
|
||||||
<span style="margin-left: 4px;">{{ buttonText }}</span>
|
<span style="margin-left: 4px">{{ buttonText }}</span>
|
||||||
</template>
|
</template>
|
||||||
</van-button>
|
</van-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -52,28 +52,42 @@ const hasClassifiedResults = computed(() => {
|
|||||||
|
|
||||||
// 按钮类型
|
// 按钮类型
|
||||||
const buttonType = computed(() => {
|
const buttonType = computed(() => {
|
||||||
if (saving.value) return 'warning'
|
if (saving.value) {
|
||||||
if (loading.value) return 'primary'
|
return 'warning'
|
||||||
if (hasClassifiedResults.value) return 'success'
|
}
|
||||||
|
if (loading.value) {
|
||||||
|
return 'primary'
|
||||||
|
}
|
||||||
|
if (hasClassifiedResults.value) {
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
return 'primary'
|
return 'primary'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 按钮图标
|
// 按钮图标
|
||||||
const buttonIcon = computed(() => {
|
const buttonIcon = computed(() => {
|
||||||
if (hasClassifiedResults.value) return 'success'
|
if (hasClassifiedResults.value) {
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
return 'fire'
|
return 'fire'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 按钮文字(非加载状态)
|
// 按钮文字(非加载状态)
|
||||||
const buttonText = computed(() => {
|
const buttonText = computed(() => {
|
||||||
if (hasClassifiedResults.value) return '保存分类'
|
if (hasClassifiedResults.value) {
|
||||||
|
return '保存分类'
|
||||||
|
}
|
||||||
return '智能分类'
|
return '智能分类'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载中文字
|
// 加载中文字
|
||||||
const loadingText = computed(() => {
|
const loadingText = computed(() => {
|
||||||
if (saving.value) return '保存中...'
|
if (saving.value) {
|
||||||
if (loading.value) return '分类中...'
|
return '保存中...'
|
||||||
|
}
|
||||||
|
if (loading.value) {
|
||||||
|
return '分类中...'
|
||||||
|
}
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,7 +106,9 @@ const handleClick = () => {
|
|||||||
* 保存分类结果
|
* 保存分类结果
|
||||||
*/
|
*/
|
||||||
const handleSaveClassify = async () => {
|
const handleSaveClassify = async () => {
|
||||||
if (saving.value || loading.value) return
|
if (saving.value || loading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -104,7 +120,7 @@ const handleSaveClassify = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 准备批量更新数据
|
// 准备批量更新数据
|
||||||
const items = classifiedResults.value.map(item => ({
|
const items = classifiedResults.value.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
classify: item.classify,
|
classify: item.classify,
|
||||||
type: item.type
|
type: item.type
|
||||||
@@ -161,7 +177,7 @@ const handleSmartClassify = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if(lockClassifiedResults.value) {
|
if (lockClassifiedResults.value) {
|
||||||
showToast('当前有分类任务正在进行,请稍后再试')
|
showToast('当前有分类任务正在进行,请稍后再试')
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
return
|
||||||
@@ -200,7 +216,7 @@ const handleSmartClassify = async () => {
|
|||||||
// 分批处理
|
// 分批处理
|
||||||
for (let i = 0; i < allTransactions.length; i += batchSize) {
|
for (let i = 0; i < allTransactions.length; i += batchSize) {
|
||||||
const batch = allTransactions.slice(i, 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 currentBatch = Math.floor(i / batchSize) + 1
|
||||||
const totalBatches = Math.ceil(allTransactions.length / batchSize)
|
const totalBatches = Math.ceil(allTransactions.length / batchSize)
|
||||||
|
|
||||||
@@ -229,7 +245,9 @@ const handleSmartClassify = async () => {
|
|||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
|
|
||||||
if (done) break
|
if (done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
@@ -238,7 +256,9 @@ const handleSmartClassify = async () => {
|
|||||||
buffer = events.pop() || '' // 保留最后一个不完整的部分
|
buffer = events.pop() || '' // 保留最后一个不完整的部分
|
||||||
|
|
||||||
for (const eventBlock of events) {
|
for (const eventBlock of events) {
|
||||||
if (!eventBlock.trim()) continue
|
if (!eventBlock.trim()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lines = eventBlock.split('\n')
|
const lines = eventBlock.split('\n')
|
||||||
@@ -276,7 +296,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) {
|
if (index !== -1) {
|
||||||
const transaction = props.transactions[index]
|
const transaction = props.transactions[index]
|
||||||
transaction.upsetedClassify = data.Classify
|
transaction.upsetedClassify = data.Classify
|
||||||
@@ -344,14 +364,14 @@ const handleSmartClassify = async () => {
|
|||||||
|
|
||||||
const removeClassifiedTransaction = (transactionId) => {
|
const removeClassifiedTransaction = (transactionId) => {
|
||||||
// 从已分类结果中移除指定ID的项
|
// 从已分类结果中移除指定ID的项
|
||||||
classifiedResults.value = classifiedResults.value.filter(item => item.id !== transactionId)
|
classifiedResults.value = classifiedResults.value.filter((item) => item.id !== transactionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置组件状态
|
* 重置组件状态
|
||||||
*/
|
*/
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
if(lockClassifiedResults.value) {
|
if (lockClassifiedResults.value) {
|
||||||
showToast('当前有分类任务正在进行,无法重置')
|
showToast('当前有分类任务正在进行,无法重置')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -365,8 +385,7 @@ const reset = () => {
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
reset,
|
reset,
|
||||||
removeClassifiedTransaction
|
removeClassifiedTransaction
|
||||||
});
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<PopupContainer
|
<PopupContainer v-model="visible" title="交易详情" height="75%" :closeable="false">
|
||||||
v-model="visible"
|
|
||||||
title="交易详情"
|
|
||||||
height="75%"
|
|
||||||
:closeable="false"
|
|
||||||
>
|
|
||||||
<template #header-actions>
|
<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>
|
</template>
|
||||||
|
|
||||||
<van-form style="margin-top: 12px;">
|
<van-form style="margin-top: 12px">
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="记录时间" :value="formatDate(transaction.createTime)" />
|
<van-cell title="记录时间" :value="formatDate(transaction.createTime)" />
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
@@ -55,32 +50,48 @@
|
|||||||
|
|
||||||
<van-field name="type" label="交易类型">
|
<van-field name="type" label="交易类型">
|
||||||
<template #input>
|
<template #input>
|
||||||
<van-radio-group v-model="editForm.type" direction="horizontal" @change="handleTypeChange">
|
<van-radio-group
|
||||||
<van-radio :name="0">支出</van-radio>
|
v-model="editForm.type"
|
||||||
<van-radio :name="1">收入</van-radio>
|
direction="horizontal"
|
||||||
<van-radio :name="2">不计</van-radio>
|
@change="handleTypeChange"
|
||||||
|
>
|
||||||
|
<van-radio :name="0"> 支出 </van-radio>
|
||||||
|
<van-radio :name="1"> 收入 </van-radio>
|
||||||
|
<van-radio :name="2"> 不计 </van-radio>
|
||||||
</van-radio-group>
|
</van-radio-group>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
|
|
||||||
<van-field name="classify" label="交易分类">
|
<van-field name="classify" label="交易分类">
|
||||||
<template #input>
|
<template #input>
|
||||||
<div style="flex: 1;">
|
<div style="flex: 1">
|
||||||
<div
|
<div
|
||||||
v-if="transaction && transaction.unconfirmedClassify && transaction.unconfirmedClassify !== editForm.classify"
|
v-if="
|
||||||
|
transaction &&
|
||||||
|
transaction.unconfirmedClassify &&
|
||||||
|
transaction.unconfirmedClassify !== editForm.classify
|
||||||
|
"
|
||||||
class="suggestion-tip"
|
class="suggestion-tip"
|
||||||
@click="applySuggestion"
|
@click="applySuggestion"
|
||||||
>
|
>
|
||||||
<van-icon name="bulb-o" class="suggestion-icon" />
|
<van-icon name="bulb-o" class="suggestion-icon" />
|
||||||
<span class="suggestion-text">
|
<span class="suggestion-text">
|
||||||
建议: {{ transaction.unconfirmedClassify }}
|
建议: {{ 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) }})
|
({{ getTypeName(transaction.unconfirmedType) }})
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="suggestion-apply">应用</div>
|
<div class="suggestion-apply">应用</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-else-if="!editForm.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
<span v-else-if="!editForm.classify" style="color: var(--van-gray-5)"
|
||||||
|
>请选择交易分类</span
|
||||||
|
>
|
||||||
<span v-else>{{ editForm.classify }}</span>
|
<span v-else>{{ editForm.classify }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -95,24 +106,14 @@
|
|||||||
</van-form>
|
</van-form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<van-button
|
<van-button round block type="primary" :loading="submitting" @click="onSubmit">
|
||||||
round
|
|
||||||
block
|
|
||||||
type="primary"
|
|
||||||
:loading="submitting"
|
|
||||||
@click="onSubmit"
|
|
||||||
>
|
|
||||||
保存修改
|
保存修改
|
||||||
</van-button>
|
</van-button>
|
||||||
</template>
|
</template>
|
||||||
</PopupContainer>
|
</PopupContainer>
|
||||||
|
|
||||||
<!-- 抵账候选列表弹窗 -->
|
<!-- 抵账候选列表弹窗 -->
|
||||||
<PopupContainer
|
<PopupContainer v-model="showOffsetPopup" title="选择抵账交易" height="75%">
|
||||||
v-model="showOffsetPopup"
|
|
||||||
title="选择抵账交易"
|
|
||||||
height="75%"
|
|
||||||
>
|
|
||||||
<van-list>
|
<van-list>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="item in offsetCandidates"
|
v-for="item in offsetCandidates"
|
||||||
@@ -154,7 +155,11 @@ import { showToast, showConfirmDialog } from 'vant'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import PopupContainer from '@/components/PopupContainer.vue'
|
import PopupContainer from '@/components/PopupContainer.vue'
|
||||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||||
import { updateTransaction, getCandidatesForOffset, offsetTransactions } from '@/api/transactionRecord'
|
import {
|
||||||
|
updateTransaction,
|
||||||
|
getCandidatesForOffset,
|
||||||
|
offsetTransactions
|
||||||
|
} from '@/api/transactionRecord'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -196,11 +201,16 @@ const occurredAtLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 监听props变化
|
// 监听props变化
|
||||||
watch(() => props.show, (newVal) => {
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newVal) => {
|
||||||
visible.value = newVal
|
visible.value = newVal
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
watch(() => props.transaction, (newVal) => {
|
watch(
|
||||||
|
() => props.transaction,
|
||||||
|
(newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
isSyncing.value = true
|
isSyncing.value = true
|
||||||
// 填充编辑表单
|
// 填充编辑表单
|
||||||
@@ -224,7 +234,8 @@ watch(() => props.transaction, (newVal) => {
|
|||||||
isSyncing.value = false
|
isSyncing.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
watch(visible, (newVal) => {
|
watch(visible, (newVal) => {
|
||||||
emit('update:show', newVal)
|
emit('update:show', newVal)
|
||||||
@@ -258,7 +269,10 @@ const onConfirmTime = ({ selectedValues }) => {
|
|||||||
const applySuggestion = () => {
|
const applySuggestion = () => {
|
||||||
if (props.transaction.unconfirmedClassify) {
|
if (props.transaction.unconfirmedClassify) {
|
||||||
editForm.classify = 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
|
editForm.type = props.transaction.unconfirmedType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,7 +328,9 @@ const handleClassifyChange = () => {
|
|||||||
|
|
||||||
// 清空分类
|
// 清空分类
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return ''
|
if (!dateString) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
return date.toLocaleString('zh-CN', {
|
return date.toLocaleString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -347,7 +363,7 @@ const handleOffsetClick = async () => {
|
|||||||
const handleCandidateSelect = (candidate) => {
|
const handleCandidateSelect = (candidate) => {
|
||||||
showConfirmDialog({
|
showConfirmDialog({
|
||||||
title: '确认抵账',
|
title: '确认抵账',
|
||||||
message: `确认将当前交易与 "${candidate.reason}" (${candidate.amount}) 互相抵消吗?\n抵消后两笔交易将被删除。`,
|
message: `确认将当前交易与 "${candidate.reason}" (${candidate.amount}) 互相抵消吗?\n抵消后两笔交易将被删除。`
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -367,7 +383,7 @@ const handleCandidateSelect = (candidate) => {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// on cancel
|
// on cancel
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -377,17 +393,18 @@ const handleCandidateSelect = (candidate) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: #ecf9ff;
|
background: var(--van-active-color);
|
||||||
color: #1989fa;
|
color: var(--van-primary-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
border: 1px solid rgba(25, 137, 250, 0.1);
|
border: 1px solid var(--van-primary-color);
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-tip:active {
|
.suggestion-tip:active {
|
||||||
opacity: 0.7;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-icon {
|
.suggestion-icon {
|
||||||
@@ -402,23 +419,12 @@ const handleCandidateSelect = (candidate) => {
|
|||||||
.suggestion-apply {
|
.suggestion-apply {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
background: #1989fa;
|
background: var(--van-primary-color);
|
||||||
color: #fff;
|
color: var(--van-white);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
font-weight: bold;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="transaction-list-container transaction-list">
|
<div class="transaction-list-container transaction-list">
|
||||||
<van-list
|
<van-list :loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
|
||||||
:loading="loading"
|
|
||||||
:finished="finished"
|
|
||||||
finished-text="没有更多了"
|
|
||||||
@load="onLoad"
|
|
||||||
>
|
|
||||||
<van-cell-group v-if="transactions && transactions.length" inset style="margin-top: 10px">
|
<van-cell-group v-if="transactions && transactions.length" inset style="margin-top: 10px">
|
||||||
<van-swipe-cell
|
<van-swipe-cell
|
||||||
v-for="transaction in transactions"
|
v-for="transaction in transactions"
|
||||||
@@ -19,10 +14,7 @@
|
|||||||
class="checkbox-col"
|
class="checkbox-col"
|
||||||
@update:model-value="toggleSelection(transaction)"
|
@update:model-value="toggleSelection(transaction)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div class="transaction-card" @click="handleClick(transaction)">
|
||||||
class="transaction-card"
|
|
||||||
@click="handleClick(transaction)"
|
|
||||||
>
|
|
||||||
<div class="card-left">
|
<div class="card-left">
|
||||||
<div class="transaction-title">
|
<div class="transaction-title">
|
||||||
<span class="reason">{{ transaction.reason || '(无摘要)' }}</span>
|
<span class="reason">{{ transaction.reason || '(无摘要)' }}</span>
|
||||||
@@ -30,34 +22,32 @@
|
|||||||
<div class="transaction-info">
|
<div class="transaction-info">
|
||||||
<div>交易时间: {{ formatDate(transaction.occurredAt) }}</div>
|
<div>交易时间: {{ formatDate(transaction.occurredAt) }}</div>
|
||||||
<div>
|
<div>
|
||||||
<span v-if="transaction.classify">
|
<span v-if="transaction.classify"> 分类: {{ transaction.classify }} </span>
|
||||||
分类: {{ transaction.classify }}
|
<span
|
||||||
</span>
|
v-if="
|
||||||
<span v-if="transaction.upsetedClassify && transaction.upsetedClassify !== transaction.classify" style="color: #ff976a">
|
transaction.upsetedClassify &&
|
||||||
|
transaction.upsetedClassify !== transaction.classify
|
||||||
|
"
|
||||||
|
style="color: var(--van-warning-color)"
|
||||||
|
>
|
||||||
→ {{ transaction.upsetedClassify }}
|
→ {{ transaction.upsetedClassify }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
|
||||||
<div v-if="transaction.importFrom">
|
|
||||||
来源: {{ transaction.importFrom }}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="transaction.importFrom">来源: {{ transaction.importFrom }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-middle">
|
<div class="card-middle">
|
||||||
<van-tag
|
<van-tag :type="getTypeTagType(transaction.type)" size="medium">
|
||||||
:type="getTypeTagType(transaction.type)"
|
|
||||||
size="medium"
|
|
||||||
>
|
|
||||||
{{ getTypeName(transaction.type) }}
|
{{ getTypeName(transaction.type) }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
<template
|
<template
|
||||||
v-if="Number.isFinite(transaction.upsetedType) && transaction.upsetedType !== transaction.type"
|
v-if="
|
||||||
|
Number.isFinite(transaction.upsetedType) &&
|
||||||
|
transaction.upsetedType !== transaction.type
|
||||||
|
"
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
<van-tag
|
<van-tag :type="getTypeTagType(transaction.upsetedType)" size="medium">
|
||||||
:type="getTypeTagType(transaction.upsetedType)"
|
|
||||||
size="medium"
|
|
||||||
>
|
|
||||||
{{ getTypeName(transaction.upsetedType) }}
|
{{ getTypeName(transaction.upsetedType) }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,11 +60,14 @@
|
|||||||
<div v-if="transaction.balance && transaction.balance > 0" class="balance">
|
<div v-if="transaction.balance && transaction.balance > 0" class="balance">
|
||||||
余额: {{ formatMoney(transaction.balance) }}
|
余额: {{ formatMoney(transaction.balance) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="transaction.refundAmount && transaction.refundAmount > 0" class="balance">
|
<div
|
||||||
|
v-if="transaction.refundAmount && transaction.refundAmount > 0"
|
||||||
|
class="balance"
|
||||||
|
>
|
||||||
退款: {{ formatMoney(transaction.refundAmount) }}
|
退款: {{ formatMoney(transaction.refundAmount) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<van-icon name="arrow" size="16" color="#c8c9cc" />
|
<van-icon name="arrow" size="16" color="var(--van-gray-5)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,16 +205,24 @@ const getTypeTagType = (type) => {
|
|||||||
|
|
||||||
// 获取金额样式类
|
// 获取金额样式类
|
||||||
const getAmountClass = (type) => {
|
const getAmountClass = (type) => {
|
||||||
if (type === 0) return 'expense'
|
if (type === 0) {
|
||||||
if (type === 1) return 'income'
|
return 'expense'
|
||||||
|
}
|
||||||
|
if (type === 1) {
|
||||||
|
return 'income'
|
||||||
|
}
|
||||||
return 'neutral'
|
return 'neutral'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化金额(带符号)
|
// 格式化金额(带符号)
|
||||||
const formatAmount = (amount, type) => {
|
const formatAmount = (amount, type) => {
|
||||||
const formatted = formatMoney(amount)
|
const formatted = formatMoney(amount)
|
||||||
if (type === 0) return `- ${formatted}`
|
if (type === 0) {
|
||||||
if (type === 1) return `+ ${formatted}`
|
return `- ${formatted}`
|
||||||
|
}
|
||||||
|
if (type === 1) {
|
||||||
|
return `+ ${formatted}`
|
||||||
|
}
|
||||||
return formatted
|
return formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +233,9 @@ const formatMoney = (amount) => {
|
|||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return ''
|
if (!dateString) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
return date.toLocaleString('zh-CN', {
|
return date.toLocaleString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -315,12 +318,12 @@ const formatDate = (dateString) => {
|
|||||||
|
|
||||||
.transaction-info {
|
.transaction-info {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.original-info {
|
.original-info {
|
||||||
color: #ff976a;
|
color: var(--van-orange);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -343,16 +346,16 @@ const formatDate = (dateString) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.amount.expense {
|
.amount.expense {
|
||||||
color: #ee0a24;
|
color: var(--van-danger-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount.income {
|
.amount.income {
|
||||||
color: #07c160;
|
color: var(--van-success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance {
|
.balance {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { createPinia } from 'pinia'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import vant from 'vant'
|
import vant from 'vant'
|
||||||
import { ConfigProvider } from 'vant';
|
import { ConfigProvider } from 'vant'
|
||||||
import 'vant/lib/index.css'
|
import 'vant/lib/index.css'
|
||||||
|
|
||||||
// 注册 Service Worker
|
// 注册 Service Worker
|
||||||
@@ -19,7 +19,7 @@ const app = createApp(App)
|
|||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(vant)
|
app.use(vant)
|
||||||
app.use(ConfigProvider);
|
app.use(ConfigProvider)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,68 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue'
|
||||||
|
|
||||||
export const needRefresh = ref(false);
|
export const needRefresh = ref(false)
|
||||||
let swRegistration = null;
|
let swRegistration = null
|
||||||
|
|
||||||
export async function updateServiceWorker() {
|
export async function updateServiceWorker() {
|
||||||
if (swRegistration && swRegistration.waiting) {
|
if (swRegistration && swRegistration.waiting) {
|
||||||
await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function register() {
|
export function register() {
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
const swUrl = `/service-worker.js`;
|
const swUrl = '/service-worker.js'
|
||||||
|
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register(swUrl)
|
.register(swUrl)
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
swRegistration = registration;
|
swRegistration = registration
|
||||||
console.log('[SW] Service Worker 注册成功:', registration.scope);
|
console.log('[SW] Service Worker 注册成功:', registration.scope)
|
||||||
|
|
||||||
// 如果已经有等待中的更新
|
// 如果已经有等待中的更新
|
||||||
if (registration.waiting) {
|
if (registration.waiting) {
|
||||||
console.log('[SW] 发现未处理的新版本');
|
console.log('[SW] 发现未处理的新版本')
|
||||||
needRefresh.value = true;
|
needRefresh.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查更新
|
// 检查更新
|
||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
const newWorker = registration.installing;
|
const newWorker = registration.installing
|
||||||
console.log('[SW] 发现新版本');
|
console.log('[SW] 发现新版本')
|
||||||
|
|
||||||
newWorker.addEventListener('statechange', () => {
|
newWorker.addEventListener('statechange', () => {
|
||||||
if (newWorker.state === 'installed') {
|
if (newWorker.state === 'installed') {
|
||||||
if (navigator.serviceWorker.controller) {
|
if (navigator.serviceWorker.controller) {
|
||||||
// 新的 Service Worker 已安装,提示用户刷新
|
// 新的 Service Worker 已安装,提示用户刷新
|
||||||
console.log('[SW] 新版本可用,请刷新页面');
|
console.log('[SW] 新版本可用,请刷新页面')
|
||||||
needRefresh.value = true;
|
needRefresh.value = true
|
||||||
} else {
|
} else {
|
||||||
// 首次安装
|
// 首次安装
|
||||||
console.log('[SW] 内容已缓存,可离线使用');
|
console.log('[SW] 内容已缓存,可离线使用')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
// 定期检查更新
|
// 定期检查更新
|
||||||
setInterval(() => {
|
setInterval(
|
||||||
registration.update();
|
() => {
|
||||||
}, 60 * 60 * 1000); // 每小时检查一次
|
registration.update()
|
||||||
|
},
|
||||||
|
60 * 60 * 1000
|
||||||
|
) // 每小时检查一次
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('[SW] Service Worker 注册失败:', error);
|
console.error('[SW] Service Worker 注册失败:', error)
|
||||||
});
|
})
|
||||||
|
|
||||||
// 监听 Service Worker 控制器变化
|
// 监听 Service Worker 控制器变化
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
console.log('[SW] 控制器已更改,页面将刷新');
|
console.log('[SW] 控制器已更改,页面将刷新')
|
||||||
window.location.reload();
|
window.location.reload()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,35 +70,37 @@ export function unregister() {
|
|||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.ready
|
navigator.serviceWorker.ready
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
registration.unregister();
|
registration.unregister()
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error.message);
|
console.error(error.message)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 请求通知权限
|
// 请求通知权限
|
||||||
export function requestNotificationPermission() {
|
export function requestNotificationPermission() {
|
||||||
if ('Notification' in window && 'serviceWorker' in navigator) {
|
if ('Notification' in window && 'serviceWorker' in navigator) {
|
||||||
Notification.requestPermission().then((permission) => {
|
Notification.requestPermission().then((permission) => {
|
||||||
if (permission === 'granted') {
|
if (permission === 'granted') {
|
||||||
console.log('[SW] 通知权限已授予');
|
console.log('[SW] 通知权限已授予')
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后台同步
|
// 后台同步
|
||||||
export function registerBackgroundSync(tag = 'sync-data') {
|
export function registerBackgroundSync(tag = 'sync-data') {
|
||||||
if ('serviceWorker' in navigator && 'SyncManager' in window) {
|
if ('serviceWorker' in navigator && 'SyncManager' in window) {
|
||||||
navigator.serviceWorker.ready.then((registration) => {
|
navigator.serviceWorker.ready
|
||||||
return registration.sync.register(tag);
|
.then((registration) => {
|
||||||
}).then(() => {
|
return registration.sync.register(tag)
|
||||||
console.log('[SW] 后台同步已注册:', tag);
|
})
|
||||||
}).catch((err) => {
|
.then(() => {
|
||||||
console.error('[SW] 后台同步注册失败:', err);
|
console.log('[SW] 后台同步已注册:', tag)
|
||||||
});
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[SW] 后台同步注册失败:', err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,106 +8,106 @@ const router = createRouter({
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('../views/LoginView.vue'),
|
component: () => import('../views/LoginView.vue'),
|
||||||
meta: { requiresAuth: false },
|
meta: { requiresAuth: false }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/balance',
|
path: '/balance',
|
||||||
name: 'balance',
|
name: 'balance',
|
||||||
component: () => import('../views/BalanceView.vue'),
|
component: () => import('../views/BalanceView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/email',
|
path: '/email',
|
||||||
name: 'email',
|
name: 'email',
|
||||||
component: () => import('../views/EmailRecord.vue'),
|
component: () => import('../views/EmailRecord.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/setting',
|
path: '/setting',
|
||||||
name: 'setting',
|
name: 'setting',
|
||||||
component: () => import('../views/SettingView.vue'),
|
component: () => import('../views/SettingView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/calendar',
|
path: '/calendar',
|
||||||
name: 'calendar',
|
name: 'calendar',
|
||||||
component: () => import('../views/CalendarView.vue'),
|
component: () => import('../views/CalendarView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/smart-classification',
|
path: '/smart-classification',
|
||||||
name: 'smart-classification',
|
name: 'smart-classification',
|
||||||
component: () => import('../views/ClassificationSmart.vue'),
|
component: () => import('../views/ClassificationSmart.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/classification-edit',
|
path: '/classification-edit',
|
||||||
name: 'classification-edit',
|
name: 'classification-edit',
|
||||||
component: () => import('../views/ClassificationEdit.vue'),
|
component: () => import('../views/ClassificationEdit.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/classification-batch',
|
path: '/classification-batch',
|
||||||
name: 'classification-batch',
|
name: 'classification-batch',
|
||||||
component: () => import('../views/ClassificationBatch.vue'),
|
component: () => import('../views/ClassificationBatch.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/classification-nlp',
|
path: '/classification-nlp',
|
||||||
name: 'classification-nlp',
|
name: 'classification-nlp',
|
||||||
component: () => import('../views/ClassificationNLP.vue'),
|
component: () => import('../views/ClassificationNLP.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'statistics',
|
name: 'statistics',
|
||||||
component: () => import('../views/StatisticsView.vue'),
|
component: () => import('../views/StatisticsView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/bill-analysis',
|
path: '/bill-analysis',
|
||||||
name: 'bill-analysis',
|
name: 'bill-analysis',
|
||||||
component: () => import('../views/BillAnalysisView.vue'),
|
component: () => import('../views/BillAnalysisView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/message',
|
path: '/message',
|
||||||
name: 'message',
|
name: 'message',
|
||||||
redirect: { path: '/balance', query: { tab: 'message' } },
|
redirect: { path: '/balance', query: { tab: 'message' } },
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/periodic-record',
|
path: '/periodic-record',
|
||||||
name: 'periodic-record',
|
name: 'periodic-record',
|
||||||
component: () => import('../views/PeriodicRecord.vue'),
|
component: () => import('../views/PeriodicRecord.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/log',
|
path: '/log',
|
||||||
name: 'log',
|
name: 'log',
|
||||||
component: () => import('../views/LogView.vue'),
|
component: () => import('../views/LogView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/budget',
|
path: '/budget',
|
||||||
name: 'budget',
|
name: 'budget',
|
||||||
component: () => import('../views/BudgetView.vue'),
|
component: () => import('../views/BudgetView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/scheduled-tasks',
|
path: '/scheduled-tasks',
|
||||||
name: 'scheduled-tasks',
|
name: 'scheduled-tasks',
|
||||||
component: () => import('../views/ScheduledTasksView.vue'),
|
component: () => import('../views/ScheduledTasksView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 待确认的分类项
|
// 待确认的分类项
|
||||||
path: '/unconfirmed-classification',
|
path: '/unconfirmed-classification',
|
||||||
name: 'unconfirmed-classification',
|
name: 'unconfirmed-classification',
|
||||||
component: () => import('../views/UnconfirmedClassification.vue'),
|
component: () => import('../views/UnconfirmedClassification.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true }
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 路由守卫
|
// 路由守卫
|
||||||
@@ -127,4 +127,3 @@ router.beforeEach((to, from, next) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const expiresAt = ref(localStorage.getItem('expiresAt') || '')
|
const expiresAt = ref(localStorage.getItem('expiresAt') || '')
|
||||||
|
|
||||||
const isAuthenticated = computed(() => {
|
const isAuthenticated = computed(() => {
|
||||||
if (!token.value || !expiresAt.value) return false
|
if (!token.value || !expiresAt.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
// 检查token是否过期
|
// 检查token是否过期
|
||||||
return new Date(expiresAt.value) > new Date()
|
return new Date(expiresAt.value) > new Date()
|
||||||
})
|
})
|
||||||
@@ -44,6 +46,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,16 +4,10 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
overscroll-behavior: contain; /* 防止滚动链传播到 body */
|
overscroll-behavior: contain; /* 防止滚动链传播到 body */
|
||||||
background: #f7f8fa;
|
background: var(--van-background);
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.page-container {
|
|
||||||
background: #141414;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 页面内容区域 */
|
/* 页面内容区域 */
|
||||||
.page-content {
|
.page-content {
|
||||||
padding: 16px 0 0 0;
|
padding: 16px 0 0 0;
|
||||||
@@ -26,20 +20,12 @@
|
|||||||
|
|
||||||
/* 统一卡片样式 */
|
/* 统一卡片样式 */
|
||||||
.common-card {
|
.common-card {
|
||||||
background: #ffffff;
|
background: var(--van-background-2);
|
||||||
margin: 0 12px 16px;
|
margin: 0 12px 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
border: 1px solid #ebedf0;
|
border: 1px solid var(--van-border-color);
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.common-card {
|
|
||||||
background: #1f1f1f;
|
|
||||||
border-color: #2c2c2c;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 卡片头部 */
|
/* 卡片头部 */
|
||||||
@@ -60,31 +46,16 @@
|
|||||||
/* 增加卡片组的对比度 */
|
/* 增加卡片组的对比度 */
|
||||||
:deep(.van-cell-group--inset) {
|
:deep(.van-cell-group--inset) {
|
||||||
margin: 10px 16px;
|
margin: 10px 16px;
|
||||||
background: #ffffff;
|
background: var(--van-background-2);
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
border: 1px solid #ebedf0;
|
border: 1px solid var(--van-border-color);
|
||||||
border-radius: 16px;
|
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) {
|
:deep(.van-cell) {
|
||||||
background: #ffffff;
|
background: var(--van-background-2);
|
||||||
border-bottom: 1px solid #ebedf0;
|
border-bottom: 1px solid var(--van-border-color);
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:deep(.van-cell) {
|
|
||||||
background: #1f1f1f;
|
|
||||||
border-bottom: 1px solid #2c2c2c;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.van-cell:last-child) {
|
:deep(.van-cell:last-child) {
|
||||||
@@ -96,31 +67,17 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #f7f8fa;
|
background: var(--van-background);
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.detail-popup {
|
|
||||||
background: #141414;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹出层内的卡片组样式 */
|
/* 弹出层内的卡片组样式 */
|
||||||
.detail-popup :deep(.van-cell-group--inset) {
|
.detail-popup :deep(.van-cell-group--inset) {
|
||||||
background: #ffffff;
|
background: var(--van-background-2);
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
border: 1px solid #ebedf0;
|
border: 1px solid var(--van-border-color);
|
||||||
border-radius: 16px;
|
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 {
|
.detail-header {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -136,7 +93,7 @@
|
|||||||
.detail-header p {
|
.detail-header p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,17 +173,11 @@
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
margin: 0 12px;
|
margin: 0 12px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
background: #ffffff;
|
background: var(--van-background-2);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.sticky-header {
|
|
||||||
background: #1f1f1f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sticky-header-text {
|
.sticky-header-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -240,11 +191,11 @@
|
|||||||
|
|
||||||
/* ===== 颜色工具类 ===== */
|
/* ===== 颜色工具类 ===== */
|
||||||
.text-expense {
|
.text-expense {
|
||||||
color: #ff6b6b;
|
color: var(--van-danger-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-income {
|
.text-income {
|
||||||
color: #51cf66;
|
color: var(--van-success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 底部操作栏 */
|
/* 底部操作栏 */
|
||||||
@@ -256,18 +207,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background-color: var(--van-background-2, #fff);
|
background-color: var(--van-background-2);
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
|
||||||
z-index: 100;
|
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 布局,确保标题固定,内容可滚动 */
|
/* 弹窗容器 - 使用 flex 布局,确保标题固定,内容可滚动 */
|
||||||
.popup-container {
|
.popup-container {
|
||||||
@@ -304,15 +248,5 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
overscroll-behavior: contain;
|
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 thead,
|
||||||
.rich-html-content tbody {
|
.rich-html-content tbody {
|
||||||
display: table;
|
display: table;
|
||||||
width: 130%;
|
width: 100%;
|
||||||
min-width: 400px; /* 确保窄屏下有足够宽度触发滚动 */
|
|
||||||
table-layout: fixed; /* 核心:强制列宽分配逻辑一致 */
|
table-layout: fixed; /* 核心:强制列宽分配逻辑一致 */
|
||||||
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-html-content tr {
|
.rich-html-content tr {
|
||||||
@@ -87,52 +87,48 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid var(--van-border-color-light);
|
border-bottom: 1px solid var(--van-border-color-light);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 th:first-child,
|
||||||
.rich-html-content td:first-child {
|
.rich-html-content td:first-child {
|
||||||
width: 30%;
|
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 {
|
.rich-html-content .income-value {
|
||||||
color: #07c160 !important;
|
color: var(--van-success-color) !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-html-content .expense-value {
|
.rich-html-content .expense-value {
|
||||||
color: #ee0a24 !important;
|
color: var(--van-danger-color) !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-html-content .highlight {
|
.rich-html-content .highlight {
|
||||||
background-color: #fffbe6;
|
background-color: var(--van-orange-light);
|
||||||
color: #ed6a0c;
|
color: var(--van-orange-dark);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
border: 1px solid #ffe58f;
|
border: 1px solid var(--van-orange);
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 暗色模式适配 */
|
/* 暗色模式适配 */
|
||||||
@media (prefers-color-scheme: dark) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
.rich-html-content .highlight {
|
.rich-html-content .highlight {
|
||||||
background-color: rgba(255, 243, 205, 0.2);
|
background-color: rgba(255, 243, 205, 0.2);
|
||||||
color: #ffc107;
|
color: #ffc107;
|
||||||
@@ -160,4 +156,4 @@
|
|||||||
.rich-html-content td {
|
.rich-html-content td {
|
||||||
border-bottom: 1px solid #2c2c2c;
|
border-bottom: 1px solid #2c2c2c;
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
|
|||||||
4
Web/src/utils/theme.js
Normal file
4
Web/src/utils/theme.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const getCssVar = (name) => {
|
||||||
|
const val = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
|
return val || '#999' // Default fallback
|
||||||
|
}
|
||||||
@@ -20,38 +20,41 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</van-nav-bar>
|
</van-nav-bar>
|
||||||
<van-tabs v-model:active="tabActive" animated>
|
<van-tabs v-model:active="tabActive" type="card" style="margin: 12px 0 2px 0">
|
||||||
<van-tab title="账单" name="balance" />
|
<van-tab title="账单" name="balance" />
|
||||||
<van-tab title="邮件" name="email" />
|
<van-tab title="邮件" name="email" />
|
||||||
<van-tab title="消息" name="message" />
|
<van-tab title="消息" name="message" />
|
||||||
</van-tabs>
|
</van-tabs>
|
||||||
|
|
||||||
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef"/>
|
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef" />
|
||||||
<EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" />
|
<EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" />
|
||||||
<MessageView v-else-if="tabActive === 'message'" ref="messageViewRef" :is-component="true" />
|
<MessageView v-else-if="tabActive === 'message'" ref="messageViewRef" :is-component="true" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router'
|
||||||
import TransactionsRecord from './TransactionsRecord.vue';
|
import TransactionsRecord from './TransactionsRecord.vue'
|
||||||
import EmailRecord from './EmailRecord.vue';
|
import EmailRecord from './EmailRecord.vue'
|
||||||
import MessageView from './MessageView.vue';
|
import MessageView from './MessageView.vue'
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute()
|
||||||
const tabActive = ref(route.query.tab || 'balance');
|
const tabActive = ref(route.query.tab || 'balance')
|
||||||
|
|
||||||
// 监听路由参数变化,用于从 tabbar 点击时切换 tab
|
// 监听路由参数变化,用于从 tabbar 点击时切换 tab
|
||||||
watch(() => route.query.tab, (newTab) => {
|
watch(
|
||||||
|
() => route.query.tab,
|
||||||
|
(newTab) => {
|
||||||
if (newTab) {
|
if (newTab) {
|
||||||
tabActive.value = newTab;
|
tabActive.value = newTab
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const transactionsRecordRef = ref(null);
|
const transactionsRecordRef = ref(null)
|
||||||
const emailRecordRef = ref(null);
|
const emailRecordRef = ref(null)
|
||||||
const messageViewRef = ref(null);
|
const messageViewRef = ref(null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- eslint-disable vue/no-v-html -->
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex">
|
<div class="page-container-flex">
|
||||||
<!-- 顶部导航栏 -->
|
<!-- 顶部导航栏 -->
|
||||||
<van-nav-bar
|
<van-nav-bar
|
||||||
title="智能分析"
|
title="智能分析"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<van-icon
|
<van-icon
|
||||||
name="setting-o"
|
name="setting-o"
|
||||||
size="20"
|
size="20"
|
||||||
style="cursor: pointer; padding-right: 12px;"
|
style="cursor: pointer; padding-right: 12px"
|
||||||
@click="onClickPrompt"
|
@click="onClickPrompt"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -33,7 +33,9 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="quick-questions">
|
<div class="quick-questions">
|
||||||
<div class="quick-title">快捷问题</div>
|
<div class="quick-title">
|
||||||
|
快捷问题
|
||||||
|
</div>
|
||||||
<van-tag
|
<van-tag
|
||||||
v-for="(q, index) in quickQuestions"
|
v-for="(q, index) in quickQuestions"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -61,7 +63,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 结果区域 -->
|
<!-- 结果区域 -->
|
||||||
<div v-if="showResult" class="result-section">
|
<div
|
||||||
|
v-if="showResult"
|
||||||
|
class="result-section"
|
||||||
|
>
|
||||||
<div class="result-header">
|
<div class="result-header">
|
||||||
<h3>分析结果</h3>
|
<h3>分析结果</h3>
|
||||||
<van-icon
|
<van-icon
|
||||||
@@ -72,12 +77,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="resultContainer" class="result-content rich-html-content">
|
<div
|
||||||
<div v-html="resultHtml"></div>
|
ref="resultContainer"
|
||||||
<van-loading v-if="analyzing" class="result-loading">
|
class="result-content rich-html-content"
|
||||||
|
>
|
||||||
|
<div v-html="resultHtml" />
|
||||||
|
<van-loading
|
||||||
|
v-if="analyzing"
|
||||||
|
class="result-loading"
|
||||||
|
>
|
||||||
AI正在分析中...
|
AI正在分析中...
|
||||||
</van-loading>
|
</van-loading>
|
||||||
<div ref="scrollAnchor"></div>
|
<div ref="scrollAnchor" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +141,11 @@ const quickQuestions = [
|
|||||||
|
|
||||||
// 返回
|
// 返回
|
||||||
const onClickLeft = () => {
|
const onClickLeft = () => {
|
||||||
|
if (window.history.length > 1) {
|
||||||
router.back()
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击提示词按钮
|
// 点击提示词按钮
|
||||||
@@ -204,10 +219,13 @@ const startAnalysis = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}/TransactionRecord/AnalyzeBill`, {
|
const response = await fetch(`${baseUrl}/TransactionRecord/AnalyzeBill`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
userInput: userInput.value
|
userInput: userInput.value
|
||||||
@@ -224,7 +242,9 @@ const startAnalysis = async () => {
|
|||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
|
|
||||||
if (done) break
|
if (done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true })
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
const lines = chunk.split('\n')
|
const lines = chunk.split('\n')
|
||||||
@@ -251,7 +271,6 @@ const startAnalysis = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('分析失败:', error)
|
console.error('分析失败:', error)
|
||||||
showToast('分析失败,请重试')
|
showToast('分析失败,请重试')
|
||||||
@@ -271,21 +290,14 @@ const startAnalysis = async () => {
|
|||||||
|
|
||||||
/* 输入区域 */
|
/* 输入区域 */
|
||||||
.input-section {
|
.input-section {
|
||||||
background: #ffffff;
|
background: var(--van-background-2);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
border: 1px solid #ebedf0;
|
border: 1px solid var(--van-border-color);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.input-section {
|
|
||||||
background: #1f1f1f;
|
|
||||||
border-color: #2c2c2c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-header h3 {
|
.input-header h3 {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -352,7 +364,7 @@ const startAnalysis = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: #ff6b6b;
|
color: var(--van-danger-color);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container calendar-container">
|
<div class="page-container calendar-container">
|
||||||
<van-calendar
|
<van-calendar
|
||||||
title="日历"
|
title="日历"
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
@select="onDateSelect"
|
@select="onDateSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ContributionHeatmap ref="heatmapRef" />
|
||||||
|
|
||||||
|
<!-- 底部安全距离 -->
|
||||||
|
<div style="height: calc(60px + env(safe-area-inset-bottom, 0px))" />
|
||||||
|
|
||||||
<!-- 日期交易列表弹出层 -->
|
<!-- 日期交易列表弹出层 -->
|
||||||
<PopupContainer
|
<PopupContainer
|
||||||
v-model="listVisible"
|
v-model="listVisible"
|
||||||
@@ -45,219 +50,228 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, nextTick, onBeforeUnmount } from "vue";
|
import { ref, onMounted, nextTick, onBeforeUnmount } from 'vue'
|
||||||
import { showToast } from "vant";
|
import { showToast } from 'vant'
|
||||||
import request from "@/api/request";
|
import request from '@/api/request'
|
||||||
import { getTransactionDetail, getTransactionsByDate } from "@/api/transactionRecord";
|
import { getTransactionDetail, getTransactionsByDate } from '@/api/transactionRecord'
|
||||||
import TransactionList from "@/components/TransactionList.vue";
|
import TransactionList from '@/components/TransactionList.vue'
|
||||||
import TransactionDetail from "@/components/TransactionDetail.vue";
|
import TransactionDetail from '@/components/TransactionDetail.vue'
|
||||||
import SmartClassifyButton from "@/components/SmartClassifyButton.vue";
|
import SmartClassifyButton from '@/components/SmartClassifyButton.vue'
|
||||||
import PopupContainer from "@/components/PopupContainer.vue";
|
import PopupContainer from '@/components/PopupContainer.vue'
|
||||||
|
import ContributionHeatmap from '@/components/ContributionHeatmap.vue'
|
||||||
|
|
||||||
const dailyStatistics = ref({});
|
const dailyStatistics = ref({})
|
||||||
const listVisible = ref(false);
|
const listVisible = ref(false)
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false)
|
||||||
const dateTransactions = ref([]);
|
const dateTransactions = ref([])
|
||||||
const currentTransaction = ref(null);
|
const currentTransaction = ref(null)
|
||||||
const listLoading = ref(false);
|
const listLoading = ref(false)
|
||||||
const selectedDate = ref(null);
|
const selectedDate = ref(null)
|
||||||
const selectedDateText = ref("");
|
const selectedDateText = ref('')
|
||||||
|
const heatmapRef = ref(null)
|
||||||
|
|
||||||
// 设置日历可选范围(例如:过去2年到未来1年)
|
// 设置日历可选范围(例如:过去2年到未来1年)
|
||||||
const minDate = new Date(new Date().getFullYear() - 2, 0, 1); // 2年前的1月1日
|
const minDate = new Date(new Date().getFullYear() - 2, 0, 1) // 2年前的1月1日
|
||||||
const maxDate = new Date(new Date().getFullYear() + 1, 11, 31); // 明年12月31日
|
const maxDate = new Date(new Date().getFullYear() + 1, 11, 31) // 明年12月31日
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick();
|
await nextTick()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 计算页面高度,滚动3/4高度以显示更多日期
|
// 计算页面高度,滚动3/4高度以显示更多日期
|
||||||
const height = document.querySelector(".calendar-container").clientHeight * 0.45;
|
const height = document.querySelector('.calendar-container').clientHeight * 0.43
|
||||||
document.querySelector(".van-calendar__body").scrollBy({
|
document.querySelector('.van-calendar__body').scrollBy({
|
||||||
top: -height,
|
top: -height,
|
||||||
behavior: "smooth",
|
behavior: 'smooth'
|
||||||
});
|
})
|
||||||
}, 300);
|
}, 300)
|
||||||
});
|
})
|
||||||
|
|
||||||
// 获取日历统计数据
|
// 获取日历统计数据
|
||||||
const fetchDailyStatistics = async (year, month) => {
|
const fetchDailyStatistics = async (year, month) => {
|
||||||
try {
|
try {
|
||||||
const response = await request.get("/TransactionRecord/GetDailyStatistics", {
|
const response = await request.get('/TransactionRecord/GetDailyStatistics', {
|
||||||
params: { year, month },
|
params: { year, month }
|
||||||
});
|
})
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// 将数组转换为对象,key为日期
|
// 将数组转换为对象,key为日期
|
||||||
const statsMap = {};
|
const statsMap = {}
|
||||||
response.data.forEach((item) => {
|
response.data.forEach((item) => {
|
||||||
|
console.warn(item)
|
||||||
statsMap[item.date] = {
|
statsMap[item.date] = {
|
||||||
count: item.count,
|
count: item.count,
|
||||||
amount: item.amount,
|
amount: (item.income - item.expense).toFixed(1)
|
||||||
};
|
}
|
||||||
});
|
})
|
||||||
dailyStatistics.value = {
|
dailyStatistics.value = {
|
||||||
...dailyStatistics.value,
|
...dailyStatistics.value,
|
||||||
...statsMap,
|
...statsMap
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取日历统计数据失败:", error);
|
console.error('获取日历统计数据失败:', error)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const smartClassifyButtonRef = ref(null);
|
const smartClassifyButtonRef = ref(null)
|
||||||
// 获取指定日期的交易列表
|
// 获取指定日期的交易列表
|
||||||
const fetchDateTransactions = async (date) => {
|
const fetchDateTransactions = async (date) => {
|
||||||
try {
|
try {
|
||||||
listLoading.value = true;
|
listLoading.value = true
|
||||||
const dateStr = date
|
const dateStr = date
|
||||||
.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" })
|
.toLocaleString('zh-CN', {
|
||||||
.replace(/\//g, "-");
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
})
|
||||||
|
.replace(/\//g, '-')
|
||||||
|
|
||||||
const response = await getTransactionsByDate(dateStr);
|
const response = await getTransactionsByDate(dateStr)
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// 根据金额从大到小排序
|
// 根据金额从大到小排序
|
||||||
dateTransactions.value = response
|
dateTransactions.value = response.data.sort((a, b) => b.amount - a.amount)
|
||||||
.data
|
|
||||||
.sort((a, b) => b.amount - a.amount);
|
|
||||||
// 重置智能分类按钮
|
// 重置智能分类按钮
|
||||||
smartClassifyButtonRef.value?.reset()
|
smartClassifyButtonRef.value?.reset()
|
||||||
} else {
|
} else {
|
||||||
dateTransactions.value = [];
|
dateTransactions.value = []
|
||||||
showToast(response.message || "获取交易列表失败");
|
showToast(response.message || '获取交易列表失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取日期交易列表失败:", error);
|
console.error('获取日期交易列表失败:', error)
|
||||||
dateTransactions.value = [];
|
dateTransactions.value = []
|
||||||
showToast("获取交易列表失败");
|
showToast('获取交易列表失败')
|
||||||
} finally {
|
} finally {
|
||||||
listLoading.value = false;
|
listLoading.value = false
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const getBalance = (transactions) => {
|
const getBalance = (transactions) => {
|
||||||
let balance = 0;
|
let balance = 0
|
||||||
transactions.forEach(tx => {
|
transactions.forEach((tx) => {
|
||||||
if(tx.type === 1) {
|
if (tx.type === 1) {
|
||||||
balance += tx.amount;
|
balance += tx.amount
|
||||||
} else if(tx.type === 0) {
|
} else if (tx.type === 0) {
|
||||||
balance -= tx.amount;
|
balance -= tx.amount
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
if(balance >= 0) {
|
if (balance >= 0) {
|
||||||
return `结余收入 ${balance.toFixed(1)} 元`;
|
return `结余收入 ${balance.toFixed(1)} 元`
|
||||||
} else {
|
} else {
|
||||||
return `结余支出 ${(-balance).toFixed(1)} 元`;
|
return `结余支出 ${(-balance).toFixed(1)} 元`
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// 当月份显示时触发
|
// 当月份显示时触发
|
||||||
const onMonthShow = ({ date }) => {
|
const onMonthShow = ({ date }) => {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear()
|
||||||
const month = date.getMonth() + 1;
|
const month = date.getMonth() + 1
|
||||||
fetchDailyStatistics(year, month);
|
fetchDailyStatistics(year, month)
|
||||||
};
|
}
|
||||||
|
|
||||||
// 日期选择事件
|
// 日期选择事件
|
||||||
const onDateSelect = (date) => {
|
const onDateSelect = (date) => {
|
||||||
selectedDate.value = date;
|
selectedDate.value = date
|
||||||
selectedDateText.value = formatSelectedDate(date);
|
selectedDateText.value = formatSelectedDate(date)
|
||||||
fetchDateTransactions(date);
|
fetchDateTransactions(date)
|
||||||
listVisible.value = true;
|
listVisible.value = true
|
||||||
};
|
}
|
||||||
|
|
||||||
// 格式化选中的日期
|
// 格式化选中的日期
|
||||||
const formatSelectedDate = (date) => {
|
const formatSelectedDate = (date) => {
|
||||||
return date.toLocaleDateString("zh-CN", {
|
return date.toLocaleDateString('zh-CN', {
|
||||||
year: "numeric",
|
year: 'numeric',
|
||||||
month: "long",
|
month: 'long',
|
||||||
day: "numeric",
|
day: 'numeric',
|
||||||
weekday: "long",
|
weekday: 'long'
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
// 查看详情
|
// 查看详情
|
||||||
const viewDetail = async (transaction) => {
|
const viewDetail = async (transaction) => {
|
||||||
try {
|
try {
|
||||||
const response = await getTransactionDetail(transaction.id);
|
const response = await getTransactionDetail(transaction.id)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
currentTransaction.value = response.data;
|
currentTransaction.value = response.data
|
||||||
detailVisible.value = true;
|
detailVisible.value = true
|
||||||
} else {
|
} else {
|
||||||
showToast(response.message || "获取详情失败");
|
showToast(response.message || '获取详情失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取详情出错:", error);
|
console.error('获取详情出错:', error)
|
||||||
showToast("获取详情失败");
|
showToast('获取详情失败')
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// 详情保存后的回调
|
// 详情保存后的回调
|
||||||
const onDetailSave = async (saveData) => {
|
const onDetailSave = async (saveData) => {
|
||||||
var item = dateTransactions.value.find(tx => tx.id === saveData.id);
|
const item = dateTransactions.value.find((tx) => tx.id === saveData.id)
|
||||||
if(!item) return
|
if (!item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 如果分类发生了变化 移除智能分类的内容,防止被智能分类覆盖
|
// 如果分类发生了变化 移除智能分类的内容,防止被智能分类覆盖
|
||||||
if(item.classify !== saveData.classify) {
|
if (item.classify !== saveData.classify) {
|
||||||
// 通知智能分类按钮组件移除指定项
|
// 通知智能分类按钮组件移除指定项
|
||||||
smartClassifyButtonRef.value?.removeClassifiedTransaction(saveData.id)
|
smartClassifyButtonRef.value?.removeClassifiedTransaction(saveData.id)
|
||||||
item.upsetedClassify = ''
|
item.upsetedClassify = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新当前日期交易列表中的数据
|
// 更新当前日期交易列表中的数据
|
||||||
Object.assign(item, saveData);
|
Object.assign(item, saveData)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 重新加载当前月份的统计数据
|
// 重新加载当前月份的统计数据
|
||||||
const now = selectedDate.value || new Date();
|
const now = selectedDate.value || new Date()
|
||||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
||||||
};
|
}
|
||||||
|
|
||||||
// 处理删除事件:从当前日期交易列表中移除,并刷新当日和当月统计
|
// 处理删除事件:从当前日期交易列表中移除,并刷新当日和当月统计
|
||||||
const handleDateTransactionDelete = async (transactionId) => {
|
const handleDateTransactionDelete = async (transactionId) => {
|
||||||
dateTransactions.value = dateTransactions.value.filter(t => t.id !== transactionId)
|
dateTransactions.value = dateTransactions.value.filter((t) => t.id !== transactionId)
|
||||||
|
|
||||||
// 刷新当前日期以及当月的统计数据
|
// 刷新当前日期以及当月的统计数据
|
||||||
const now = selectedDate.value || new Date();
|
const now = selectedDate.value || new Date()
|
||||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
||||||
};
|
}
|
||||||
|
|
||||||
// 智能分类保存回调
|
// 智能分类保存回调
|
||||||
const onSmartClassifySave = async () => {
|
const onSmartClassifySave = async () => {
|
||||||
// 保存完成后重新加载数据
|
// 保存完成后重新加载数据
|
||||||
if (selectedDate.value) {
|
if (selectedDate.value) {
|
||||||
await fetchDateTransactions(selectedDate.value);
|
await fetchDateTransactions(selectedDate.value)
|
||||||
}
|
}
|
||||||
// 重新加载统计数据
|
// 重新加载统计数据
|
||||||
const now = selectedDate.value || new Date();
|
const now = selectedDate.value || new Date()
|
||||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatterCalendar = (day) => {
|
const formatterCalendar = (day) => {
|
||||||
const dayCopy = { ...day };
|
const dayCopy = { ...day }
|
||||||
if (dayCopy.date.toDateString() === new Date().toDateString()) {
|
if (dayCopy.date.toDateString() === new Date().toDateString()) {
|
||||||
dayCopy.text = "今天";
|
dayCopy.text = '今天'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化日期为 yyyy-MM-dd
|
// 格式化日期为 yyyy-MM-dd
|
||||||
const dateKey = dayCopy.date
|
const dateKey = dayCopy.date
|
||||||
.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" })
|
.toLocaleString('zh-CN', {
|
||||||
.replace(/\//g, "-");
|
year: 'numeric',
|
||||||
const stats = dailyStatistics.value[dateKey];
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
})
|
||||||
|
.replace(/\//g, '-')
|
||||||
|
const stats = dailyStatistics.value[dateKey]
|
||||||
|
|
||||||
if (stats) {
|
if (stats) {
|
||||||
dayCopy.topInfo = `${stats.count}笔`; // 展示消费笔数
|
dayCopy.topInfo = `${stats.count}笔` // 展示消费笔数
|
||||||
dayCopy.bottomInfo = `${stats.amount.toFixed(1)}元`; // 展示消费金额
|
dayCopy.bottomInfo = `${stats.amount}元` // 展示消费金额
|
||||||
}
|
}
|
||||||
|
|
||||||
return dayCopy;
|
return dayCopy
|
||||||
};
|
}
|
||||||
|
|
||||||
// 初始加载当前月份数据
|
// 初始加载当前月份数据
|
||||||
const now = new Date();
|
const now = new Date()
|
||||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
||||||
|
|
||||||
// 全局删除事件监听,确保日历页面数据一致
|
// 全局删除事件监听,确保日历页面数据一致
|
||||||
const onGlobalTransactionDeleted = () => {
|
const onGlobalTransactionDeleted = () => {
|
||||||
@@ -266,12 +280,15 @@ const onGlobalTransactionDeleted = () => {
|
|||||||
}
|
}
|
||||||
const now = selectedDate.value || new Date()
|
const now = selectedDate.value || new Date()
|
||||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
||||||
|
heatmapRef.value?.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
window.addEventListener &&
|
||||||
|
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
window.removeEventListener &&
|
||||||
|
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当有交易被新增/修改/批量更新时刷新
|
// 当有交易被新增/修改/批量更新时刷新
|
||||||
@@ -281,33 +298,39 @@ const onGlobalTransactionsChanged = () => {
|
|||||||
}
|
}
|
||||||
const now = selectedDate.value || new Date()
|
const now = selectedDate.value || new Date()
|
||||||
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
||||||
|
heatmapRef.value?.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
|
window.addEventListener &&
|
||||||
|
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
window.removeEventListener &&
|
||||||
|
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.van-calendar{
|
:deep(.van-calendar__header-title){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.van-calendar {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-container {
|
.calendar-container {
|
||||||
/* 使用准确的视口高度减去 TabBar 高度(50px)和安全区域 */
|
/* 使用准确的视口高度减去 TabBar 高度(50px)和安全区域 */
|
||||||
height: calc(var(--vh, 100vh) - 50px - env(safe-area-inset-bottom, 0px));
|
|
||||||
max-height: calc(var(--vh, 100vh) - 50px - env(safe-area-inset-bottom, 0px));
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
background-color: var(--van-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-container :deep(.van-calendar) {
|
.calendar-container :deep(.van-calendar) {
|
||||||
height: 100% !important;
|
height: calc(auto + 40px) !important;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -328,4 +351,8 @@ onBeforeUnmount(() => {
|
|||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Add margin to bottom of heatmap to separate from tabbar */
|
||||||
|
:deep(.heatmap-card) {
|
||||||
|
flex-shrink: 0; /* Prevent heatmap from shrinking */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -106,7 +106,11 @@ const onLoad = async () => {
|
|||||||
|
|
||||||
// 返回上一页
|
// 返回上一页
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
|
if (window.history.length > 1) {
|
||||||
router.back()
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面加载
|
// 页面加载
|
||||||
|
|||||||
@@ -26,12 +26,7 @@
|
|||||||
<div v-else class="level-container">
|
<div v-else class="level-container">
|
||||||
<!-- 面包屑导航 -->
|
<!-- 面包屑导航 -->
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<van-tag
|
<van-tag type="primary" closeable style="margin-left: 16px" @close="handleBackToRoot">
|
||||||
type="primary"
|
|
||||||
closeable
|
|
||||||
style="margin-left: 16px;"
|
|
||||||
@close="handleBackToRoot"
|
|
||||||
>
|
|
||||||
{{ currentTypeName }}
|
{{ currentTypeName }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,34 +36,20 @@
|
|||||||
|
|
||||||
<van-cell-group v-else inset>
|
<van-cell-group v-else inset>
|
||||||
<van-swipe-cell v-for="category in categories" :key="category.id">
|
<van-swipe-cell v-for="category in categories" :key="category.id">
|
||||||
<van-cell
|
<van-cell :title="category.name" is-link @click="handleEdit(category)" />
|
||||||
:title="category.name"
|
|
||||||
is-link
|
|
||||||
@click="handleEdit(category)"
|
|
||||||
/>
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<van-button
|
<van-button square type="danger" text="删除" @click="handleDelete(category)" />
|
||||||
square
|
|
||||||
type="danger"
|
|
||||||
text="删除"
|
|
||||||
@click="handleDelete(category)"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</van-swipe-cell>
|
</van-swipe-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部安全距离 -->
|
<!-- 底部安全距离 -->
|
||||||
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div>
|
<div style="height: calc(55px + env(safe-area-inset-bottom, 0px))" />
|
||||||
|
|
||||||
<div class="bottom-button">
|
<div class="bottom-button">
|
||||||
<!-- 新增分类按钮 -->
|
<!-- 新增分类按钮 -->
|
||||||
<van-button
|
<van-button type="primary" size="large" icon="plus" @click="handleAddCategory">
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
icon="plus"
|
|
||||||
@click="handleAddCategory"
|
|
||||||
>
|
|
||||||
新增分类
|
新增分类
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,12 +104,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import {
|
import { showSuccessToast, showToast, showLoadingToast, closeToast } from 'vant'
|
||||||
showSuccessToast,
|
|
||||||
showToast,
|
|
||||||
showLoadingToast,
|
|
||||||
closeToast
|
|
||||||
} from 'vant'
|
|
||||||
import {
|
import {
|
||||||
getCategoryList,
|
getCategoryList,
|
||||||
createCategory,
|
createCategory,
|
||||||
@@ -149,7 +125,7 @@ const typeOptions = [
|
|||||||
const currentLevel = ref(0) // 0=类型选择, 1=分类管理
|
const currentLevel = ref(0) // 0=类型选择, 1=分类管理
|
||||||
const currentType = ref(null) // 当前选中的交易类型
|
const currentType = ref(null) // 当前选中的交易类型
|
||||||
const currentTypeName = computed(() => {
|
const currentTypeName = computed(() => {
|
||||||
const type = typeOptions.find(t => t.value === currentType.value)
|
const type = typeOptions.find((t) => t.value === currentType.value)
|
||||||
return type ? type.label : ''
|
return type ? type.label : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,7 +202,11 @@ const handleBack = () => {
|
|||||||
currentType.value = null
|
currentType.value = null
|
||||||
categories.value = []
|
categories.value = []
|
||||||
} else {
|
} else {
|
||||||
|
if (window.history.length > 1) {
|
||||||
router.back()
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +320,9 @@ const handleDelete = async (category) => {
|
|||||||
* 确认删除
|
* 确认删除
|
||||||
*/
|
*/
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (!deleteTarget.value) return
|
if (!deleteTarget.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showLoadingToast({
|
showLoadingToast({
|
||||||
@@ -382,7 +364,6 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.level-container {
|
.level-container {
|
||||||
min-height: calc(100vh - 50px);
|
min-height: calc(100vh - 50px);
|
||||||
@@ -398,11 +379,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 深色模式 */
|
/* 深色模式 */
|
||||||
@media (prefers-color-scheme: dark) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
.level-container {
|
.level-container {
|
||||||
background: #1a1a1a;
|
background: var(--van-background);
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
|
|
||||||
/* 设置页面容器背景色 */
|
/* 设置页面容器背景色 */
|
||||||
:deep(.van-nav-bar) {
|
:deep(.van-nav-bar) {
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex classification-nlp">
|
<div class="page-container-flex classification-nlp">
|
||||||
<van-nav-bar
|
<van-nav-bar title="自然语言分类" left-text="返回" left-arrow @click-left="onClickLeft" />
|
||||||
title="自然语言分类"
|
|
||||||
left-text="返回"
|
|
||||||
left-arrow
|
|
||||||
@click-left="onClickLeft"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="scroll-content">
|
<div class="scroll-content">
|
||||||
<!-- 输入区域 -->
|
<!-- 输入区域 -->
|
||||||
@@ -23,13 +18,7 @@
|
|||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<van-button
|
<van-button type="primary" block round :loading="analyzing" @click="handleAnalyze">
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
round
|
|
||||||
:loading="analyzing"
|
|
||||||
@click="handleAnalyze"
|
|
||||||
>
|
|
||||||
分析查询
|
分析查询
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,30 +48,12 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 记录列表弹窗 -->
|
<!-- 记录列表弹窗 -->
|
||||||
<PopupContainer
|
<PopupContainer v-model="showRecordsList" title="交易记录列表" height="75%">
|
||||||
v-model="showRecordsList"
|
<div style="background: var(--van-background)">
|
||||||
title="交易记录列表"
|
|
||||||
height="75%"
|
|
||||||
>
|
|
||||||
<div style="background: var(--van-background, #f7f8fa);">
|
|
||||||
<!-- 批量操作按钮 -->
|
<!-- 批量操作按钮 -->
|
||||||
<div class="batch-actions">
|
<div class="batch-actions">
|
||||||
<van-button
|
<van-button plain type="primary" size="small" @click="selectAll"> 全选 </van-button>
|
||||||
plain
|
<van-button plain type="default" size="small" @click="selectNone"> 全不选 </van-button>
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
@click="selectAll"
|
|
||||||
>
|
|
||||||
全选
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
|
||||||
plain
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
@click="selectNone"
|
|
||||||
>
|
|
||||||
全不选
|
|
||||||
</van-button>
|
|
||||||
<van-button
|
<van-button
|
||||||
type="success"
|
type="success"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -133,14 +104,20 @@ const showRecordsList = ref(false) // 控制记录列表弹窗
|
|||||||
|
|
||||||
// 返回按钮
|
// 返回按钮
|
||||||
const onClickLeft = () => {
|
const onClickLeft = () => {
|
||||||
|
if (window.history.length > 1) {
|
||||||
router.back()
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将带目标分类的记录转换为普通交易记录格式供列表显示
|
// 将带目标分类的记录转换为普通交易记录格式供列表显示
|
||||||
const displayRecords = computed(() => {
|
const displayRecords = computed(() => {
|
||||||
if (!analysisResult.value) return []
|
if (!analysisResult.value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
return analysisResult.value.records.map(r => ({
|
return analysisResult.value.records.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
reason: r.reason,
|
reason: r.reason,
|
||||||
amount: r.amount,
|
amount: r.amount,
|
||||||
@@ -183,7 +160,7 @@ const handleAnalyze = async () => {
|
|||||||
analysisResult.value = response.data
|
analysisResult.value = response.data
|
||||||
|
|
||||||
// 默认全选
|
// 默认全选
|
||||||
const allIds = new Set(response.data.records.map(r => r.id))
|
const allIds = new Set(response.data.records.map((r) => r.id))
|
||||||
selectedIds.value = allIds
|
selectedIds.value = allIds
|
||||||
|
|
||||||
showToast(`找到 ${response.data.records.length} 条记录`)
|
showToast(`找到 ${response.data.records.length} 条记录`)
|
||||||
@@ -200,8 +177,10 @@ const handleAnalyze = async () => {
|
|||||||
|
|
||||||
// 全选
|
// 全选
|
||||||
const selectAll = () => {
|
const selectAll = () => {
|
||||||
if (!analysisResult.value) return
|
if (!analysisResult.value) {
|
||||||
const allIds = new Set(analysisResult.value.records.map(r => r.id))
|
return
|
||||||
|
}
|
||||||
|
const allIds = new Set(analysisResult.value.records.map((r) => r.id))
|
||||||
selectedIds.value = allIds
|
selectedIds.value = allIds
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +197,7 @@ const updateSelectedIds = (newSelectedIds) => {
|
|||||||
// 点击记录查看详情
|
// 点击记录查看详情
|
||||||
const handleRecordClick = (transaction) => {
|
const handleRecordClick = (transaction) => {
|
||||||
// 从原始记录中获取完整信息
|
// 从原始记录中获取完整信息
|
||||||
const record = analysisResult.value?.records.find(r => r.id === transaction.id)
|
const record = analysisResult.value?.records.find((r) => r.id === transaction.id)
|
||||||
if (record) {
|
if (record) {
|
||||||
currentTransaction.value = {
|
currentTransaction.value = {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -266,8 +245,8 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
// 构建批量更新数据(使用AI修改后的结果)
|
// 构建批量更新数据(使用AI修改后的结果)
|
||||||
const items = analysisResult.value.records
|
const items = analysisResult.value.records
|
||||||
.filter(r => selectedIds.value.has(r.id))
|
.filter((r) => selectedIds.value.has(r.id))
|
||||||
.map(r => ({
|
.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
classify: r.upsetedClassify,
|
classify: r.upsetedClassify,
|
||||||
type: r.upsetedType
|
type: r.upsetedType
|
||||||
@@ -320,7 +299,7 @@ const handleSubmit = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background-color: var(--van-background-2, #fff);
|
background-color: var(--van-background-2);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex smart-classification">
|
<div class="page-container-flex smart-classification">
|
||||||
<van-nav-bar
|
<van-nav-bar title="智能分类" left-text="返回" left-arrow @click-left="onClickLeft" />
|
||||||
title="智能分类"
|
|
||||||
left-text="返回"
|
|
||||||
left-arrow
|
|
||||||
@click-left="onClickLeft"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="scroll-content" style="padding-top: 5px;">
|
<div class="scroll-content" style="padding-top: 5px">
|
||||||
<!-- 统计信息 -->
|
<!-- 统计信息 -->
|
||||||
<div class="stats-info">
|
<div class="stats-info">
|
||||||
<span class="stats-label">未分类账单 </span>
|
<span class="stats-label">未分类账单 </span>
|
||||||
@@ -23,7 +18,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 底部安全距离 -->
|
<!-- 底部安全距离 -->
|
||||||
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
|
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部操作按钮 -->
|
<!-- 底部操作按钮 -->
|
||||||
@@ -56,11 +51,7 @@
|
|||||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
|
import { showToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
|
||||||
import {
|
import { getUnclassifiedCount, smartClassify, batchUpdateClassify } from '@/api/transactionRecord'
|
||||||
getUnclassifiedCount,
|
|
||||||
smartClassify,
|
|
||||||
batchUpdateClassify
|
|
||||||
} from '@/api/transactionRecord'
|
|
||||||
import ReasonGroupList from '@/components/ReasonGroupList.vue'
|
import ReasonGroupList from '@/components/ReasonGroupList.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -74,7 +65,9 @@ const suppressDataChanged = ref(false)
|
|||||||
|
|
||||||
// 计算已选中的数量
|
// 计算已选中的数量
|
||||||
const selectedCount = computed(() => {
|
const selectedCount = computed(() => {
|
||||||
if (!groupListRef.value) return 0
|
if (!groupListRef.value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
return groupListRef.value.getSelectedReasons().size
|
return groupListRef.value.getSelectedReasons().size
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -114,18 +107,30 @@ const onClickLeft = () => {
|
|||||||
if (hasChanges.value) {
|
if (hasChanges.value) {
|
||||||
showConfirmDialog({
|
showConfirmDialog({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
message: '有未保存的分类结果,确定要离开吗?',
|
message: '有未保存的分类结果,确定要离开吗?'
|
||||||
}).then(() => {
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (window.history.length > 1) {
|
||||||
router.back()
|
router.back()
|
||||||
}).catch(() => {})
|
|
||||||
} else {
|
} else {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
} else {
|
||||||
|
if (window.history.length > 1) {
|
||||||
router.back()
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始智能分类
|
// 开始智能分类
|
||||||
const startClassify = async () => {
|
const startClassify = async () => {
|
||||||
if (!groupListRef.value) return
|
if (!groupListRef.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 获取所有选中分组
|
// 获取所有选中分组
|
||||||
const selectedGroups = groupListRef.value.getList(true)
|
const selectedGroups = groupListRef.value.getList(true)
|
||||||
@@ -167,14 +172,18 @@ const startClassify = async () => {
|
|||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
|
|
||||||
if (done) break
|
if (done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
buffer += decoder.decode(value, { stream: true })
|
||||||
const lines = buffer.split('\n\n')
|
const lines = buffer.split('\n\n')
|
||||||
buffer = lines.pop() || ''
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue
|
if (!line.trim()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const eventMatch = line.match(/^event: (.+)$/m)
|
const eventMatch = line.match(/^event: (.+)$/m)
|
||||||
const dataMatch = line.match(/^data: (.+)$/m)
|
const dataMatch = line.match(/^data: (.+)$/m)
|
||||||
@@ -215,8 +224,9 @@ const handleSSEEvent = (eventType, data, classifyResults) => {
|
|||||||
let braceCount = 0
|
let braceCount = 0
|
||||||
let closeBrace = -1
|
let closeBrace = -1
|
||||||
for (let i = openBrace; i < classifyBuffer.value.length; i++) {
|
for (let i = openBrace; i < classifyBuffer.value.length; i++) {
|
||||||
if (classifyBuffer.value[i] === '{') braceCount++
|
if (classifyBuffer.value[i] === '{') {
|
||||||
else if (classifyBuffer.value[i] === '}') {
|
braceCount++
|
||||||
|
} else if (classifyBuffer.value[i] === '}') {
|
||||||
braceCount--
|
braceCount--
|
||||||
if (braceCount === 0) {
|
if (braceCount === 0) {
|
||||||
closeBrace = i
|
closeBrace = i
|
||||||
@@ -283,7 +293,9 @@ const handleSSEEvent = (eventType, data, classifyResults) => {
|
|||||||
|
|
||||||
// 保存分类
|
// 保存分类
|
||||||
const saveClassifications = async () => {
|
const saveClassifications = async () => {
|
||||||
if (!groupListRef.value) return
|
if (!groupListRef.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 收集所有已分类的账单
|
// 收集所有已分类的账单
|
||||||
const groups = groupListRef.value.getList()
|
const groups = groupListRef.value.getList()
|
||||||
@@ -346,7 +358,7 @@ onMounted(async () => {
|
|||||||
.stats-info {
|
.stats-info {
|
||||||
padding: 12px 12px 0 16px;
|
padding: 12px 12px 0 16px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-value {
|
.stats-value {
|
||||||
|
|||||||
@@ -2,9 +2,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex">
|
<div class="page-container-flex">
|
||||||
<!-- 下拉刷新区域 -->
|
<!-- 下拉刷新区域 -->
|
||||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
<van-pull-refresh
|
||||||
|
v-model="refreshing"
|
||||||
|
@refresh="onRefresh"
|
||||||
|
>
|
||||||
<!-- 加载提示 -->
|
<!-- 加载提示 -->
|
||||||
<van-loading v-if="loading && !(emailList && emailList.length)" vertical style="padding: 50px 0">
|
<van-loading
|
||||||
|
v-if="loading && !(emailList && emailList.length)"
|
||||||
|
vertical
|
||||||
|
style="padding: 50px 0"
|
||||||
|
>
|
||||||
加载中...
|
加载中...
|
||||||
</van-loading>
|
</van-loading>
|
||||||
<!-- 邮件列表 -->
|
<!-- 邮件列表 -->
|
||||||
@@ -14,7 +21,11 @@
|
|||||||
finished-text="没有更多了"
|
finished-text="没有更多了"
|
||||||
@load="onLoad"
|
@load="onLoad"
|
||||||
>
|
>
|
||||||
<van-cell-group v-if="emailList && emailList.length" inset style="margin-top: 10px">
|
<van-cell-group
|
||||||
|
v-if="emailList && emailList.length"
|
||||||
|
inset
|
||||||
|
style="margin-top: 10px"
|
||||||
|
>
|
||||||
<van-swipe-cell
|
<van-swipe-cell
|
||||||
v-for="email in emailList"
|
v-for="email in emailList"
|
||||||
:key="email.id"
|
:key="email.id"
|
||||||
@@ -27,11 +38,15 @@
|
|||||||
>
|
>
|
||||||
<template #value>
|
<template #value>
|
||||||
<div class="email-info">
|
<div class="email-info">
|
||||||
<div class="email-date">{{ formatDate(email.receivedDate) }}</div>
|
<div class="email-date">
|
||||||
<div v-if="email.transactionCount > 0" class="bill-count">
|
{{ formatDate(email.receivedDate) }}
|
||||||
<span style="font-size: 12px;">已解析{{ email.transactionCount }}条账单</span>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="email.transactionCount > 0"
|
||||||
|
class="bill-count"
|
||||||
|
>
|
||||||
|
<span style="font-size: 12px">已解析{{ email.transactionCount }}条账单</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
@@ -54,13 +69,13 @@
|
|||||||
</van-list>
|
</van-list>
|
||||||
|
|
||||||
<!-- 底部安全距离 -->
|
<!-- 底部安全距离 -->
|
||||||
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
|
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
|
||||||
</van-pull-refresh>
|
</van-pull-refresh>
|
||||||
|
|
||||||
<!-- 详情弹出层 -->
|
<!-- 详情弹出层 -->
|
||||||
<PopupContainer
|
<PopupContainer
|
||||||
v-model="detailVisible"
|
v-model="detailVisible"
|
||||||
:title="currentEmail ? (currentEmail.Subject || currentEmail.subject || '(无主题)') : ''"
|
:title="currentEmail ? currentEmail.Subject || currentEmail.subject || '(无主题)' : ''"
|
||||||
height="75%"
|
height="75%"
|
||||||
>
|
>
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
@@ -75,10 +90,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="currentEmail">
|
<div v-if="currentEmail">
|
||||||
<van-cell-group inset style="margin-top: 12px;">
|
<van-cell-group
|
||||||
<van-cell title="发件人" :value="currentEmail.From || currentEmail.from || '未知'" />
|
inset
|
||||||
<van-cell title="接收时间" :value="formatDate(currentEmail.ReceivedDate || currentEmail.receivedDate)" />
|
style="margin-top: 12px"
|
||||||
<van-cell title="记录时间" :value="formatDate(currentEmail.CreateTime || currentEmail.createTime)" />
|
>
|
||||||
|
<van-cell
|
||||||
|
title="发件人"
|
||||||
|
:value="currentEmail.From || currentEmail.from || '未知'"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="接收时间"
|
||||||
|
:value="formatDate(currentEmail.ReceivedDate || currentEmail.receivedDate)"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="记录时间"
|
||||||
|
:value="formatDate(currentEmail.CreateTime || currentEmail.createTime)"
|
||||||
|
/>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0"
|
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0"
|
||||||
title="已解析账单数"
|
title="已解析账单数"
|
||||||
@@ -88,21 +115,26 @@
|
|||||||
/>
|
/>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
<div class="email-content">
|
<div class="email-content">
|
||||||
<h4 style="margin-left: 10px;">邮件内容</h4>
|
<h4 style="margin-left: 10px">
|
||||||
|
邮件内容
|
||||||
|
</h4>
|
||||||
<div
|
<div
|
||||||
v-if="currentEmail.htmlBody"
|
v-if="currentEmail.htmlBody"
|
||||||
class="content-body html-content"
|
class="content-body html-content"
|
||||||
v-html="currentEmail.htmlBody"
|
v-html="currentEmail.htmlBody"
|
||||||
></div>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else-if="currentEmail.body"
|
v-else-if="currentEmail.body"
|
||||||
class="content-body"
|
class="content-body"
|
||||||
>
|
>
|
||||||
{{ currentEmail.body }}
|
{{ currentEmail.body }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="content-body empty-content">
|
<div
|
||||||
|
v-else
|
||||||
|
class="content-body empty-content"
|
||||||
|
>
|
||||||
暂无邮件内容
|
暂无邮件内容
|
||||||
<div style="font-size: 12px; margin-top: 8px; color: #999;">
|
<div style="font-size: 12px; margin-top: 8px; color: var(--van-gray-6)">
|
||||||
Debug: {{ Object.keys(currentEmail).join(', ') }}
|
Debug: {{ Object.keys(currentEmail).join(', ') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +171,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { showToast, showConfirmDialog } from 'vant'
|
import { showToast, showConfirmDialog } from 'vant'
|
||||||
import { getEmailList, getEmailDetail, deleteEmail, refreshTransactionRecords, syncEmails, getEmailTransactions } from '@/api/emailRecord'
|
import {
|
||||||
|
getEmailList,
|
||||||
|
getEmailDetail,
|
||||||
|
deleteEmail,
|
||||||
|
refreshTransactionRecords,
|
||||||
|
syncEmails,
|
||||||
|
getEmailTransactions
|
||||||
|
} from '@/api/emailRecord'
|
||||||
import { getTransactionDetail } from '@/api/transactionRecord'
|
import { getTransactionDetail } from '@/api/transactionRecord'
|
||||||
import TransactionList from '@/components/TransactionList.vue'
|
import TransactionList from '@/components/TransactionList.vue'
|
||||||
import TransactionDetail from '@/components/TransactionDetail.vue'
|
import TransactionDetail from '@/components/TransactionDetail.vue'
|
||||||
@@ -163,7 +202,9 @@ const currentTransaction = ref(null)
|
|||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
const loadData = async (isRefresh = false) => {
|
const loadData = async (isRefresh = false) => {
|
||||||
if (loading.value) return // 防止重复加载
|
if (loading.value) {
|
||||||
|
return
|
||||||
|
} // 防止重复加载
|
||||||
|
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
pageIndex.value = 1
|
pageIndex.value = 1
|
||||||
@@ -246,7 +287,7 @@ const handleDelete = async (email) => {
|
|||||||
try {
|
try {
|
||||||
await showConfirmDialog({
|
await showConfirmDialog({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
message: '确定要删除这封邮件吗?',
|
message: '确定要删除这封邮件吗?'
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await deleteEmail(email.id)
|
const response = await deleteEmail(email.id)
|
||||||
@@ -266,12 +307,14 @@ const handleDelete = async (email) => {
|
|||||||
|
|
||||||
// 重新分析
|
// 重新分析
|
||||||
const handleRefreshAnalysis = async () => {
|
const handleRefreshAnalysis = async () => {
|
||||||
if (!currentEmail.value) return
|
if (!currentEmail.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await showConfirmDialog({
|
await showConfirmDialog({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
message: '确定要重新分析该邮件并刷新交易记录吗?',
|
message: '确定要重新分析该邮件并刷新交易记录吗?'
|
||||||
})
|
})
|
||||||
|
|
||||||
refreshingAnalysis.value = true
|
refreshingAnalysis.value = true
|
||||||
@@ -316,7 +359,9 @@ const handleSync = async () => {
|
|||||||
|
|
||||||
// 查看关联的账单列表
|
// 查看关联的账单列表
|
||||||
const viewTransactions = async () => {
|
const viewTransactions = async () => {
|
||||||
if (!currentEmail.value) return
|
if (!currentEmail.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const emailId = currentEmail.value.id
|
const emailId = currentEmail.value.id
|
||||||
@@ -340,18 +385,22 @@ const onGlobalTransactionDeleted = (e) => {
|
|||||||
// 如果交易列表弹窗打开,尝试重新加载邮箱的交易列表
|
// 如果交易列表弹窗打开,尝试重新加载邮箱的交易列表
|
||||||
if (transactionListVisible.value && currentEmail.value) {
|
if (transactionListVisible.value && currentEmail.value) {
|
||||||
const emailId = currentEmail.value.id || currentEmail.value.Id
|
const emailId = currentEmail.value.id || currentEmail.value.Id
|
||||||
getEmailTransactions(emailId).then(response => {
|
getEmailTransactions(emailId)
|
||||||
|
.then((response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
transactionList.value = response.data || []
|
transactionList.value = response.data || []
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
window.addEventListener &&
|
||||||
|
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
window.removeEventListener &&
|
||||||
|
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听新增/修改/批量更新事件,刷新弹窗内交易或邮件列表
|
// 监听新增/修改/批量更新事件,刷新弹窗内交易或邮件列表
|
||||||
@@ -359,21 +408,25 @@ const onGlobalTransactionsChanged = (e) => {
|
|||||||
console.log('收到全局交易变更事件:', e)
|
console.log('收到全局交易变更事件:', e)
|
||||||
if (transactionListVisible.value && currentEmail.value) {
|
if (transactionListVisible.value && currentEmail.value) {
|
||||||
const emailId = currentEmail.value.id || currentEmail.value.Id
|
const emailId = currentEmail.value.id || currentEmail.value.Id
|
||||||
getEmailTransactions(emailId).then(response => {
|
getEmailTransactions(emailId)
|
||||||
|
.then((response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
transactionList.value = response.data || []
|
transactionList.value = response.data || []
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
})
|
||||||
|
.catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
// 也刷新邮件列表以保持统计一致
|
// 也刷新邮件列表以保持统计一致
|
||||||
loadData(true)
|
loadData(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
|
window.addEventListener &&
|
||||||
|
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
window.removeEventListener &&
|
||||||
|
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 处理点击账单
|
// 处理点击账单
|
||||||
@@ -394,7 +447,7 @@ const handleTransactionClick = async (transaction) => {
|
|||||||
|
|
||||||
const handleTransactionDelete = (transactionId) => {
|
const handleTransactionDelete = (transactionId) => {
|
||||||
// 从当前的交易列表中移除该交易
|
// 从当前的交易列表中移除该交易
|
||||||
transactionList.value = transactionList.value.filter(t => t.id !== transactionId)
|
transactionList.value = transactionList.value.filter((t) => t.id !== transactionId)
|
||||||
|
|
||||||
// 刷新邮件列表
|
// 刷新邮件列表
|
||||||
loadData(true)
|
loadData(true)
|
||||||
@@ -402,16 +455,15 @@ const handleTransactionDelete = (transactionId) => {
|
|||||||
// 刷新当前邮件详情
|
// 刷新当前邮件详情
|
||||||
if (currentEmail.value) {
|
if (currentEmail.value) {
|
||||||
const emailId = currentEmail.value.id
|
const emailId = currentEmail.value.id
|
||||||
getEmailDetail(emailId).then(response => {
|
getEmailDetail(emailId).then((response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
currentEmail.value = response.data
|
currentEmail.value = response.data
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(new CustomEvent('transaction-deleted', { detail: transactionId }))
|
||||||
new CustomEvent('transaction-deleted', { detail: transactionId }))
|
} catch (e) {
|
||||||
} catch(e) {
|
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,21 +480,22 @@ const handleTransactionSave = async () => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(
|
new CustomEvent('transactions-changed', {
|
||||||
'transactions-changed',
|
|
||||||
{
|
|
||||||
detail: {
|
detail: {
|
||||||
emailId: currentEmail.value?.id
|
emailId: currentEmail.value?.id
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
} catch(e) {
|
)
|
||||||
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return ''
|
if (!dateString) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
return date.toLocaleString('zh-CN', {
|
return date.toLocaleString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -454,8 +507,6 @@ const formatDate = (dateString) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData(true)
|
loadData(true)
|
||||||
})
|
})
|
||||||
@@ -467,7 +518,6 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
:deep(.van-pull-refresh) {
|
:deep(.van-pull-refresh) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -487,7 +537,7 @@ defineExpose({
|
|||||||
|
|
||||||
.email-date {
|
.email-date {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,12 +563,12 @@ defineExpose({
|
|||||||
margin: 0 20px;
|
margin: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
.content-body {
|
.content-body {
|
||||||
background-color: #2c2c2c;
|
background-color: var(--van-background-2);
|
||||||
border: 1px solid #3a3a3a;
|
border: 1px solid var(--van-border-color);
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
|
|
||||||
.delete-button {
|
.delete-button {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex log-view">
|
<div class="page-container-flex log-view">
|
||||||
<van-nav-bar
|
<van-nav-bar
|
||||||
title="查看日志"
|
title="查看日志"
|
||||||
@@ -20,16 +20,36 @@
|
|||||||
|
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<van-dropdown-menu>
|
<van-dropdown-menu>
|
||||||
<van-dropdown-item v-model="selectedLevel" :options="levelOptions" @change="handleSearch" />
|
<van-dropdown-item
|
||||||
<van-dropdown-item v-model="selectedDate" :options="dateOptions" @change="handleSearch" />
|
v-model="selectedLevel"
|
||||||
|
:options="levelOptions"
|
||||||
|
@change="handleSearch"
|
||||||
|
/>
|
||||||
|
<van-dropdown-item
|
||||||
|
v-model="selectedDate"
|
||||||
|
:options="dateOptions"
|
||||||
|
@change="handleDateChange"
|
||||||
|
/>
|
||||||
|
<van-dropdown-item
|
||||||
|
v-model="selectedClassName"
|
||||||
|
:options="classNameOptions"
|
||||||
|
@change="handleClassNameChange"
|
||||||
|
/>
|
||||||
</van-dropdown-menu>
|
</van-dropdown-menu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 下拉刷新区域 -->
|
<!-- 下拉刷新区域 -->
|
||||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
<van-pull-refresh
|
||||||
|
v-model="refreshing"
|
||||||
|
@refresh="onRefresh"
|
||||||
|
>
|
||||||
<!-- 加载提示 -->
|
<!-- 加载提示 -->
|
||||||
<van-loading v-if="loading && !logList.length" vertical style="padding: 50px 0">
|
<van-loading
|
||||||
|
v-if="loading && !logList.length"
|
||||||
|
vertical
|
||||||
|
style="padding: 50px 0"
|
||||||
|
>
|
||||||
加载中...
|
加载中...
|
||||||
</van-loading>
|
</van-loading>
|
||||||
|
|
||||||
@@ -51,7 +71,34 @@
|
|||||||
<span class="log-level">{{ log.level }}</span>
|
<span class="log-level">{{ log.level }}</span>
|
||||||
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
|
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-message">{{ log.message }}</div>
|
<div
|
||||||
|
v-if="log.className || log.methodName"
|
||||||
|
class="log-source"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="log.className"
|
||||||
|
class="source-class"
|
||||||
|
>{{ log.className }}</span>
|
||||||
|
<span
|
||||||
|
v-if="log.methodName"
|
||||||
|
class="source-method"
|
||||||
|
>.{{ log.methodName }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="log.requestId"
|
||||||
|
class="log-request-id"
|
||||||
|
>
|
||||||
|
<span class="request-id-label">请求ID:</span>
|
||||||
|
<span
|
||||||
|
class="request-id-value"
|
||||||
|
@click="handleRequestIdClick(log.requestId)"
|
||||||
|
>
|
||||||
|
{{ log.requestId }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-message">
|
||||||
|
{{ log.message }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
@@ -63,7 +110,7 @@
|
|||||||
</van-list>
|
</van-list>
|
||||||
|
|
||||||
<!-- 底部安全距离 -->
|
<!-- 底部安全距离 -->
|
||||||
<div style="height: 20px"></div>
|
<div style="height: 20px" />
|
||||||
</van-pull-refresh>
|
</van-pull-refresh>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +120,7 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
import { getLogList, getAvailableDates } from '@/api/log'
|
import { getLogList, getAvailableDates, getAvailableClassNames, getLogsByRequestId } from '@/api/log'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -93,6 +140,11 @@ const total = ref(0)
|
|||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const selectedLevel = ref('')
|
const selectedLevel = ref('')
|
||||||
const selectedDate = ref('')
|
const selectedDate = ref('')
|
||||||
|
const selectedClassName = ref('')
|
||||||
|
|
||||||
|
// requestId 查询模式
|
||||||
|
const isRequestIdMode = ref(false)
|
||||||
|
const currentRequestId = ref('')
|
||||||
|
|
||||||
// 日志级别选项
|
// 日志级别选项
|
||||||
const levelOptions = ref([
|
const levelOptions = ref([
|
||||||
@@ -106,15 +158,20 @@ const levelOptions = ref([
|
|||||||
])
|
])
|
||||||
|
|
||||||
// 日期选项
|
// 日期选项
|
||||||
const dateOptions = ref([
|
const dateOptions = ref([{ text: '全部日期', value: '' }])
|
||||||
{ text: '全部日期', value: '' }
|
|
||||||
])
|
// 类名选项
|
||||||
|
const classNameOptions = ref([{ text: '全部类名', value: '' }])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回上一页
|
* 返回上一页
|
||||||
*/
|
*/
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
|
if (window.history.length > 1) {
|
||||||
router.back()
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,12 +179,12 @@ const handleBack = () => {
|
|||||||
*/
|
*/
|
||||||
const getLevelClass = (level) => {
|
const getLevelClass = (level) => {
|
||||||
const levelMap = {
|
const levelMap = {
|
||||||
'ERR': 'level-error',
|
ERR: 'level-error',
|
||||||
'FTL': 'level-fatal',
|
FTL: 'level-fatal',
|
||||||
'WRN': 'level-warning',
|
WRN: 'level-warning',
|
||||||
'INF': 'level-info',
|
INF: 'level-info',
|
||||||
'DBG': 'level-debug',
|
DBG: 'level-debug',
|
||||||
'VRB': 'level-verbose'
|
VRB: 'level-verbose'
|
||||||
}
|
}
|
||||||
return levelMap[level] || 'level-default'
|
return levelMap[level] || 'level-default'
|
||||||
}
|
}
|
||||||
@@ -145,7 +202,9 @@ const formatTime = (timestamp) => {
|
|||||||
* 加载日志数据
|
* 加载日志数据
|
||||||
*/
|
*/
|
||||||
const loadLogs = async (reset = false) => {
|
const loadLogs = async (reset = false) => {
|
||||||
if (fetching.value) return
|
if (fetching.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fetching.value = true
|
fetching.value = true
|
||||||
|
|
||||||
@@ -156,6 +215,17 @@ const loadLogs = async (reset = false) => {
|
|||||||
finished.value = false
|
finished.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let response
|
||||||
|
|
||||||
|
if (isRequestIdMode.value) {
|
||||||
|
// requestId 查询模式
|
||||||
|
response = await getLogsByRequestId({
|
||||||
|
requestId: currentRequestId.value,
|
||||||
|
pageIndex: pageIndex.value,
|
||||||
|
pageSize: pageSize.value
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 普通查询模式
|
||||||
const params = {
|
const params = {
|
||||||
pageIndex: pageIndex.value,
|
pageIndex: pageIndex.value,
|
||||||
pageSize: pageSize.value
|
pageSize: pageSize.value
|
||||||
@@ -170,8 +240,12 @@ const loadLogs = async (reset = false) => {
|
|||||||
if (selectedDate.value) {
|
if (selectedDate.value) {
|
||||||
params.date = selectedDate.value
|
params.date = selectedDate.value
|
||||||
}
|
}
|
||||||
|
if (selectedClassName.value) {
|
||||||
|
params.className = selectedClassName.value
|
||||||
|
}
|
||||||
|
|
||||||
const response = await getLogList(params)
|
response = await getLogList(params)
|
||||||
|
}
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
const newLogs = response.data || []
|
const newLogs = response.data || []
|
||||||
@@ -185,12 +259,9 @@ const loadLogs = async (reset = false) => {
|
|||||||
total.value = response.total
|
total.value = response.total
|
||||||
|
|
||||||
// 判断是否还有更多数据
|
// 判断是否还有更多数据
|
||||||
// total = -1 表示总数未知,此时只根据返回数据量判断
|
|
||||||
if (total.value === -1) {
|
if (total.value === -1) {
|
||||||
// 如果返回的数据少于请求的数量,说明没有更多了
|
|
||||||
finished.value = newLogs.length < pageSize.value
|
finished.value = newLogs.length < pageSize.value
|
||||||
} else {
|
} else {
|
||||||
// 如果有明确的总数,则判断是否已加载完全部数据
|
|
||||||
if (logList.value.length >= total.value || newLogs.length < pageSize.value) {
|
if (logList.value.length >= total.value || newLogs.length < pageSize.value) {
|
||||||
finished.value = true
|
finished.value = true
|
||||||
} else {
|
} else {
|
||||||
@@ -216,14 +287,21 @@ const loadLogs = async (reset = false) => {
|
|||||||
* 下拉刷新
|
* 下拉刷新
|
||||||
*/
|
*/
|
||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
|
if (isRequestIdMode.value) {
|
||||||
|
// requestId 模式下刷新,重置为第一页
|
||||||
await loadLogs(true)
|
await loadLogs(true)
|
||||||
|
} else {
|
||||||
|
await loadLogs(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载更多
|
* 加载更多
|
||||||
*/
|
*/
|
||||||
const onLoad = async () => {
|
const onLoad = async () => {
|
||||||
if (finished.value || fetching.value) return
|
if (finished.value || fetching.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是第一次加载
|
// 如果是第一次加载
|
||||||
if (pageIndex.value === 1 && logList.value.length === 0) {
|
if (pageIndex.value === 1 && logList.value.length === 0) {
|
||||||
@@ -239,6 +317,8 @@ const onLoad = async () => {
|
|||||||
* 搜索处理
|
* 搜索处理
|
||||||
*/
|
*/
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
|
isRequestIdMode.value = false
|
||||||
|
currentRequestId.value = ''
|
||||||
loadLogs(true)
|
loadLogs(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,20 +337,55 @@ const loadAvailableDates = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await getAvailableDates()
|
const response = await getAvailableDates()
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const dates = response.data.map(date => ({
|
const dates = response.data.map((date) => ({
|
||||||
text: formatDate(date),
|
text: formatDate(date),
|
||||||
value: date
|
value: date
|
||||||
}))
|
}))
|
||||||
dateOptions.value = [
|
dateOptions.value = [{ text: '全部日期', value: '' }, ...dates]
|
||||||
{ text: '全部日期', value: '' },
|
|
||||||
...dates
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载日期列表失败:', error)
|
console.error('加载日期列表失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载可用类名列表
|
||||||
|
*/
|
||||||
|
const loadAvailableClassNames = async () => {
|
||||||
|
try {
|
||||||
|
const params = {}
|
||||||
|
if (selectedDate.value) {
|
||||||
|
params.date = selectedDate.value
|
||||||
|
}
|
||||||
|
const response = await getAvailableClassNames(params)
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const classNames = response.data.map((name) => ({
|
||||||
|
text: name,
|
||||||
|
value: name
|
||||||
|
}))
|
||||||
|
classNameOptions.value = [{ text: '全部类名', value: '' }, ...classNames]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载类名列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日期改变时重新加载类名
|
||||||
|
*/
|
||||||
|
const handleDateChange = async () => {
|
||||||
|
selectedClassName.value = ''
|
||||||
|
await loadAvailableClassNames()
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类名改变时重新搜索
|
||||||
|
*/
|
||||||
|
const handleClassNameChange = () => {
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化日期显示
|
* 格式化日期显示
|
||||||
*/
|
*/
|
||||||
@@ -282,9 +397,50 @@ const formatDate = (dateStr) => {
|
|||||||
return dateStr
|
return dateStr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理请求ID点击
|
||||||
|
*/
|
||||||
|
const handleRequestIdClick = async (requestId) => {
|
||||||
|
try {
|
||||||
|
showToast('正在查询关联日志...')
|
||||||
|
|
||||||
|
isRequestIdMode.value = true
|
||||||
|
currentRequestId.value = requestId
|
||||||
|
|
||||||
|
const response = await getLogsByRequestId({
|
||||||
|
requestId,
|
||||||
|
pageIndex: 1,
|
||||||
|
pageSize: 100
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success && response.data && response.data.length > 0) {
|
||||||
|
logList.value = response.data
|
||||||
|
total.value = response.total
|
||||||
|
pageIndex.value = 1
|
||||||
|
|
||||||
|
// 根据返回数据量判断是否还有更多
|
||||||
|
if (response.data.length < 100) {
|
||||||
|
finished.value = true
|
||||||
|
} else {
|
||||||
|
finished.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`找到 ${response.total} 条关联日志`)
|
||||||
|
} else {
|
||||||
|
showToast('未找到关联日志')
|
||||||
|
logList.value = []
|
||||||
|
finished.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('查询关联日志失败:', error)
|
||||||
|
showToast('查询失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
// 组件挂载时加载数据
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAvailableDates()
|
loadAvailableDates()
|
||||||
|
loadAvailableClassNames()
|
||||||
// 不在这里调用 loadLogs,让 van-list 的 @load 事件自动触发
|
// 不在这里调用 loadLogs,让 van-list 的 @load 事件自动触发
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -292,27 +448,27 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.log-view {
|
.log-view {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: #f5f5f5;
|
background-color: var(--van-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
.log-view {
|
.log-view {
|
||||||
background-color: #1a1a1a;
|
background-color: #1a1a1a;
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
|
|
||||||
.filter-section {
|
.filter-section {
|
||||||
background-color: #ffffff;
|
background-color: var(--van-background-2);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
.filter-section {
|
.filter-section {
|
||||||
background-color: #2c2c2c;
|
background-color: #2c2c2c;
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -323,8 +479,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-item {
|
.log-item {
|
||||||
background-color: #ffffff;
|
background-color: var(--van-background-2);
|
||||||
border-left: 3px solid #1989fa;
|
border-left: 3px solid var(--van-primary-color);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -338,7 +494,7 @@ onMounted(() => {
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
.log-item {
|
.log-item {
|
||||||
background-color: #2c2c2c;
|
background-color: #2c2c2c;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||||
@@ -347,7 +503,7 @@ onMounted(() => {
|
|||||||
.log-item:hover {
|
.log-item:hover {
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
|
|
||||||
.log-header {
|
.log-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -379,6 +535,43 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-request-id {
|
||||||
|
margin: 2px 0;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-source {
|
||||||
|
margin: 2px 0;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-class {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--van-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-id-label {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-id-value {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--van-primary-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-id-value:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.log-message {
|
.log-message {
|
||||||
color: #323233;
|
color: #323233;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -398,20 +591,20 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.level-debug .log-level {
|
.level-debug .log-level {
|
||||||
background-color: #1989fa;
|
background-color: var(--van-primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-info .log-level {
|
.level-info .log-level {
|
||||||
background-color: #07c160;
|
background-color: var(--van-success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-warning .log-level {
|
.level-warning .log-level {
|
||||||
background-color: #ff976a;
|
background-color: var(--van-orange);
|
||||||
border-left-color: #ff976a;
|
border-left-color: var(--van-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-error .log-level {
|
.level-error .log-level {
|
||||||
background-color: #ee0a24;
|
background-color: var(--van-danger-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-fatal .log-level {
|
.level-fatal .log-level {
|
||||||
@@ -419,7 +612,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.level-default .log-level {
|
.level-default .log-level {
|
||||||
background-color: #646566;
|
background-color: var(--van-gray-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-verbose {
|
.level-verbose {
|
||||||
@@ -427,19 +620,19 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.level-debug {
|
.level-debug {
|
||||||
border-left-color: #1989fa;
|
border-left-color: var(--van-primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-info {
|
.level-info {
|
||||||
border-left-color: #07c160;
|
border-left-color: var(--van-success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-warning {
|
.level-warning {
|
||||||
border-left-color: #ff976a;
|
border-left-color: var(--van-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-error {
|
.level-error {
|
||||||
border-left-color: #ee0a24;
|
border-left-color: var(--van-danger-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-fatal {
|
.level-fatal {
|
||||||
@@ -447,7 +640,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.level-default {
|
.level-default {
|
||||||
border-left-color: #646566;
|
border-left-color: var(--van-gray-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 优化下拉菜单样式 */
|
/* 优化下拉菜单样式 */
|
||||||
@@ -455,9 +648,9 @@ onMounted(() => {
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
:deep(.van-dropdown-menu) {
|
:deep(.van-dropdown-menu) {
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
<!-- eslint-disable vue/no-v-html -->
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex">
|
<div class="page-container-flex">
|
||||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
<van-pull-refresh
|
||||||
|
v-model="refreshing"
|
||||||
|
@refresh="onRefresh"
|
||||||
|
>
|
||||||
<van-list
|
<van-list
|
||||||
v-model:loading="loading"
|
v-model:loading="loading"
|
||||||
:finished="finished"
|
:finished="finished"
|
||||||
finished-text="没有更多了"
|
finished-text="没有更多了"
|
||||||
@load="onLoad"
|
@load="onLoad"
|
||||||
>
|
>
|
||||||
<van-cell-group v-if="list.length" inset style="margin-top: 10px">
|
<van-cell-group
|
||||||
<van-swipe-cell v-for="item in list" :key="item.id">
|
v-if="list.length"
|
||||||
<div class="message-card" @click="viewDetail(item)">
|
inset
|
||||||
|
style="margin-top: 10px"
|
||||||
|
>
|
||||||
|
<van-swipe-cell
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="message-card"
|
||||||
|
@click="viewDetail(item)"
|
||||||
|
>
|
||||||
<div class="card-left">
|
<div class="card-left">
|
||||||
<div class="message-title" :class="{ 'unread': !item.isRead }">
|
<div
|
||||||
|
class="message-title"
|
||||||
|
:class="{ unread: !item.isRead }"
|
||||||
|
>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
@@ -23,16 +39,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-right">
|
<div class="card-right">
|
||||||
<van-tag v-if="!item.isRead" type="danger">未读</van-tag>
|
<van-tag
|
||||||
<van-icon name="arrow" size="16" class="arrow-icon" />
|
v-if="!item.isRead"
|
||||||
|
type="danger"
|
||||||
|
>
|
||||||
|
未读
|
||||||
|
</van-tag>
|
||||||
|
<van-icon
|
||||||
|
name="arrow"
|
||||||
|
size="16"
|
||||||
|
class="arrow-icon"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #right>
|
<template #right>
|
||||||
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(item)" />
|
<van-button
|
||||||
|
square
|
||||||
|
text="删除"
|
||||||
|
type="danger"
|
||||||
|
class="delete-button"
|
||||||
|
@click="handleDelete(item)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</van-swipe-cell>
|
</van-swipe-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
<van-empty v-else-if="!loading" description="暂无消息" />
|
<van-empty
|
||||||
|
v-else-if="!loading"
|
||||||
|
description="暂无消息"
|
||||||
|
/>
|
||||||
</van-list>
|
</van-list>
|
||||||
</van-pull-refresh>
|
</van-pull-refresh>
|
||||||
|
|
||||||
@@ -47,13 +81,23 @@
|
|||||||
v-if="currentMessage.messageType === 2"
|
v-if="currentMessage.messageType === 2"
|
||||||
class="detail-content rich-html-content"
|
class="detail-content rich-html-content"
|
||||||
v-html="currentMessage.content"
|
v-html="currentMessage.content"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="detail-content"
|
||||||
>
|
>
|
||||||
</div>
|
|
||||||
<div v-else class="detail-content">
|
|
||||||
{{ currentMessage.content }}
|
{{ currentMessage.content }}
|
||||||
</div>
|
</div>
|
||||||
<template v-if="currentMessage.url && currentMessage.messageType === 1" #footer>
|
<template
|
||||||
<van-button type="primary" block round @click="handleUrlJump(currentMessage.url)">
|
v-if="currentMessage.url && currentMessage.messageType === 1"
|
||||||
|
#footer
|
||||||
|
>
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
@click="handleUrlJump(currentMessage.url)"
|
||||||
|
>
|
||||||
查看详情
|
查看详情
|
||||||
</van-button>
|
</van-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -62,164 +106,166 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast, showDialog } from 'vant';
|
import { showToast, showDialog } from 'vant'
|
||||||
import { getMessageList, markAsRead, deleteMessage, markAllAsRead } from '@/api/message';
|
import { getMessageList, markAsRead, deleteMessage, markAllAsRead } from '@/api/message'
|
||||||
import { useMessageStore } from '@/stores/message';
|
import { useMessageStore } from '@/stores/message'
|
||||||
import PopupContainer from '@/components/PopupContainer.vue';
|
import PopupContainer from '@/components/PopupContainer.vue'
|
||||||
|
|
||||||
const messageStore = useMessageStore();
|
const messageStore = useMessageStore()
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
const list = ref([]);
|
const list = ref([])
|
||||||
const loading = ref(false);
|
const loading = ref(false)
|
||||||
const finished = ref(false);
|
const finished = ref(false)
|
||||||
const refreshing = ref(false);
|
const refreshing = ref(false)
|
||||||
const pageIndex = ref(1);
|
const pageIndex = ref(1)
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(20)
|
||||||
|
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false)
|
||||||
const currentMessage = ref({});
|
const currentMessage = ref({})
|
||||||
|
|
||||||
const onLoad = async () => {
|
const onLoad = async () => {
|
||||||
if (refreshing.value) {
|
if (refreshing.value) {
|
||||||
list.value = [];
|
list.value = []
|
||||||
pageIndex.value = 1;
|
pageIndex.value = 1
|
||||||
refreshing.value = false;
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await getMessageList({
|
const res = await getMessageList({
|
||||||
pageIndex: pageIndex.value,
|
pageIndex: pageIndex.value,
|
||||||
pageSize: pageSize.value
|
pageSize: pageSize.value
|
||||||
});
|
})
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
const data = res.data.map(item => ({
|
const data = res.data.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
createTime: new Date(item.createTime).toLocaleString()
|
createTime: new Date(item.createTime).toLocaleString()
|
||||||
}));
|
}))
|
||||||
|
|
||||||
if (pageIndex.value === 1) {
|
if (pageIndex.value === 1) {
|
||||||
list.value = data;
|
list.value = data
|
||||||
} else {
|
} else {
|
||||||
list.value = [...list.value, ...data];
|
list.value = [...list.value, ...data]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否加载完成
|
// 判断是否加载完成
|
||||||
if (list.value.length >= res.total || data.length < pageSize.value) {
|
if (list.value.length >= res.total || data.length < pageSize.value) {
|
||||||
finished.value = true;
|
finished.value = true
|
||||||
} else {
|
} else {
|
||||||
pageIndex.value++;
|
pageIndex.value++
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast(res.message || '加载失败');
|
showToast(res.message || '加载失败')
|
||||||
finished.value = true;
|
finished.value = true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error)
|
||||||
showToast('加载失败');
|
showToast('加载失败')
|
||||||
finished.value = true;
|
finished.value = true
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const onRefresh = () => {
|
const onRefresh = () => {
|
||||||
finished.value = false;
|
finished.value = false
|
||||||
loading.value = true;
|
loading.value = true
|
||||||
onLoad();
|
onLoad()
|
||||||
};
|
}
|
||||||
|
|
||||||
const viewDetail = async (item) => {
|
const viewDetail = async (item) => {
|
||||||
if (!item.isRead) {
|
if (!item.isRead) {
|
||||||
try {
|
try {
|
||||||
await markAsRead(item.id);
|
await markAsRead(item.id)
|
||||||
item.isRead = true;
|
item.isRead = true
|
||||||
messageStore.updateUnreadCount();
|
messageStore.updateUnreadCount()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('标记已读失败', error);
|
console.error('标记已读失败', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentMessage.value = item;
|
currentMessage.value = item
|
||||||
detailVisible.value = true;
|
detailVisible.value = true
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleUrlJump = (targetUrl) => {
|
const handleUrlJump = (targetUrl) => {
|
||||||
if (!targetUrl) return;
|
if (!targetUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (targetUrl.startsWith('http')) {
|
if (targetUrl.startsWith('http')) {
|
||||||
window.open(targetUrl, '_blank');
|
window.open(targetUrl, '_blank')
|
||||||
} else if (targetUrl.startsWith('/')) {
|
} else if (targetUrl.startsWith('/')) {
|
||||||
router.push(targetUrl);
|
router.push(targetUrl)
|
||||||
detailVisible.value = false;
|
detailVisible.value = false
|
||||||
} else {
|
} else {
|
||||||
showToast('无效的URL');
|
showToast('无效的URL')
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDelete = (item) => {
|
const handleDelete = (item) => {
|
||||||
showDialog({
|
showDialog({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
message: '确定要删除这条消息吗?',
|
message: '确定要删除这条消息吗?',
|
||||||
showCancelButton: true,
|
showCancelButton: true
|
||||||
}).then(async (action) => {
|
}).then(async (action) => {
|
||||||
if (action === 'confirm') {
|
if (action === 'confirm') {
|
||||||
try {
|
try {
|
||||||
const res = await deleteMessage(item.id);
|
const res = await deleteMessage(item.id)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
showToast('删除成功');
|
showToast('删除成功')
|
||||||
const wasUnread = !item.isRead;
|
const wasUnread = !item.isRead
|
||||||
list.value = list.value.filter(i => i.id !== item.id);
|
list.value = list.value.filter((i) => i.id !== item.id)
|
||||||
if (wasUnread) {
|
if (wasUnread) {
|
||||||
messageStore.updateUnreadCount();
|
messageStore.updateUnreadCount()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast(res.message || '删除失败');
|
showToast(res.message || '删除失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除消息失败', error);
|
console.error('删除消息失败', error)
|
||||||
showToast('删除失败');
|
showToast('删除失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleMarkAllRead = () => {
|
const handleMarkAllRead = () => {
|
||||||
showDialog({
|
showDialog({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
message: '确定要将所有消息标记为已读吗?',
|
message: '确定要将所有消息标记为已读吗?',
|
||||||
showCancelButton: true,
|
showCancelButton: true
|
||||||
}).then(async (action) => {
|
}).then(async (action) => {
|
||||||
if (action === 'confirm') {
|
if (action === 'confirm') {
|
||||||
try {
|
try {
|
||||||
const res = await markAllAsRead();
|
const res = await markAllAsRead()
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
showToast('操作成功');
|
showToast('操作成功')
|
||||||
// 刷新列表
|
// 刷新列表
|
||||||
onRefresh();
|
onRefresh()
|
||||||
// 更新未读计数
|
// 更新未读计数
|
||||||
messageStore.updateUnreadCount();
|
messageStore.updateUnreadCount()
|
||||||
} else {
|
} else {
|
||||||
showToast(res.message || '操作失败');
|
showToast(res.message || '操作失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('标记所有已读失败', error);
|
console.error('标记所有已读失败', error)
|
||||||
showToast('操作失败');
|
showToast('操作失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// onLoad 会由 van-list 自动触发
|
// onLoad 会由 van-list 自动触发
|
||||||
});
|
})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
handleMarkAllRead
|
handleMarkAllRead
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -257,7 +303,7 @@ defineExpose({
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content{
|
.message-content {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--van-text-color-2);
|
color: var(--van-text-color-2);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
|||||||
@@ -9,9 +9,16 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 下拉刷新区域 -->
|
<!-- 下拉刷新区域 -->
|
||||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
<van-pull-refresh
|
||||||
|
v-model="refreshing"
|
||||||
|
@refresh="onRefresh"
|
||||||
|
>
|
||||||
<!-- 加载提示 -->
|
<!-- 加载提示 -->
|
||||||
<van-loading v-if="loading && !periodicList.length" vertical style="padding: 50px 0">
|
<van-loading
|
||||||
|
v-if="loading && !periodicList.length"
|
||||||
|
vertical
|
||||||
|
style="padding: 50px 0"
|
||||||
|
>
|
||||||
加载中...
|
加载中...
|
||||||
</van-loading>
|
</van-loading>
|
||||||
|
|
||||||
@@ -23,10 +30,18 @@
|
|||||||
class="periodic-list"
|
class="periodic-list"
|
||||||
@load="onLoad"
|
@load="onLoad"
|
||||||
>
|
>
|
||||||
<van-cell-group v-for="item in periodicList" :key="item.id" inset class="periodic-item">
|
<van-cell-group
|
||||||
|
v-for="item in periodicList"
|
||||||
|
:key="item.id"
|
||||||
|
inset
|
||||||
|
class="periodic-item"
|
||||||
|
>
|
||||||
<van-swipe-cell>
|
<van-swipe-cell>
|
||||||
<div @click="editPeriodic(item)">
|
<div @click="editPeriodic(item)">
|
||||||
<van-cell :title="item.reason || '无摘要'" :label="getPeriodicTypeText(item)">
|
<van-cell
|
||||||
|
:title="item.reason || '无摘要'"
|
||||||
|
:label="getPeriodicTypeText(item)"
|
||||||
|
>
|
||||||
<template #value>
|
<template #value>
|
||||||
<div class="amount-info">
|
<div class="amount-info">
|
||||||
<span :class="['amount', item.type === 1 ? 'income' : 'expense']">
|
<span :class="['amount', item.type === 1 ? 'income' : 'expense']">
|
||||||
@@ -35,8 +50,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
<van-cell title="分类" :value="item.classify || '未分类'" />
|
<van-cell
|
||||||
<van-cell title="下次执行时间" :value="formatDateTime(item.nextExecuteTime) || '未设置'" />
|
title="分类"
|
||||||
|
:value="item.classify || '未分类'"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="下次执行时间"
|
||||||
|
:value="formatDateTime(item.nextExecuteTime) || '未设置'"
|
||||||
|
/>
|
||||||
<van-cell title="状态">
|
<van-cell title="状态">
|
||||||
<template #value>
|
<template #value>
|
||||||
<van-switch
|
<van-switch
|
||||||
@@ -69,7 +90,7 @@
|
|||||||
</van-list>
|
</van-list>
|
||||||
|
|
||||||
<!-- 底部安全距离 -->
|
<!-- 底部安全距离 -->
|
||||||
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
|
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
|
||||||
</van-pull-refresh>
|
</van-pull-refresh>
|
||||||
|
|
||||||
<!-- 底部新增按钮 -->
|
<!-- 底部新增按钮 -->
|
||||||
@@ -92,7 +113,10 @@
|
|||||||
height="75%"
|
height="75%"
|
||||||
>
|
>
|
||||||
<van-form>
|
<van-form>
|
||||||
<van-cell-group inset title="周期设置">
|
<van-cell-group
|
||||||
|
inset
|
||||||
|
title="周期设置"
|
||||||
|
>
|
||||||
<van-field
|
<van-field
|
||||||
v-model="form.periodicTypeText"
|
v-model="form.periodicTypeText"
|
||||||
is-link
|
is-link
|
||||||
@@ -153,7 +177,10 @@
|
|||||||
/>
|
/>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-cell-group inset title="基本信息">
|
<van-cell-group
|
||||||
|
inset
|
||||||
|
title="基本信息"
|
||||||
|
>
|
||||||
<van-field
|
<van-field
|
||||||
v-model="form.reason"
|
v-model="form.reason"
|
||||||
name="reason"
|
name="reason"
|
||||||
@@ -179,16 +206,31 @@
|
|||||||
label="类型"
|
label="类型"
|
||||||
>
|
>
|
||||||
<template #input>
|
<template #input>
|
||||||
<van-radio-group v-model="form.type" direction="horizontal">
|
<van-radio-group
|
||||||
<van-radio :name="0">支出</van-radio>
|
v-model="form.type"
|
||||||
<van-radio :name="1">收入</van-radio>
|
direction="horizontal"
|
||||||
<van-radio :name="2">不计</van-radio>
|
>
|
||||||
|
<van-radio :value="0">
|
||||||
|
支出
|
||||||
|
</van-radio>
|
||||||
|
<van-radio :value="1">
|
||||||
|
收入
|
||||||
|
</van-radio>
|
||||||
|
<van-radio :value="2">
|
||||||
|
不计
|
||||||
|
</van-radio>
|
||||||
</van-radio-group>
|
</van-radio-group>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
<van-field name="classify" label="分类">
|
<van-field
|
||||||
|
name="classify"
|
||||||
|
label="分类"
|
||||||
|
>
|
||||||
<template #input>
|
<template #input>
|
||||||
<span v-if="!form.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
<span
|
||||||
|
v-if="!form.classify"
|
||||||
|
style="color: var(--van-gray-5)"
|
||||||
|
>请选择交易分类</span>
|
||||||
<span v-else>{{ form.classify }}</span>
|
<span v-else>{{ form.classify }}</span>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
@@ -201,14 +243,25 @@
|
|||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
</van-form>
|
</van-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<van-button round block type="primary" :loading="submitting" @click="submit">
|
<van-button
|
||||||
|
round
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
{{ isEdit ? '更新' : '确认添加' }}
|
{{ isEdit ? '更新' : '确认添加' }}
|
||||||
</van-button>
|
</van-button>
|
||||||
</template>
|
</template>
|
||||||
</PopupContainer>
|
</PopupContainer>
|
||||||
|
|
||||||
<!-- 周期类型选择器 -->
|
<!-- 周期类型选择器 -->
|
||||||
<van-popup v-model:show="showPeriodicTypePicker" position="bottom" round teleport="body">
|
<van-popup
|
||||||
|
v-model:show="showPeriodicTypePicker"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
teleport="body"
|
||||||
|
>
|
||||||
<van-picker
|
<van-picker
|
||||||
:columns="periodicTypeColumns"
|
:columns="periodicTypeColumns"
|
||||||
@confirm="onPeriodicTypeConfirm"
|
@confirm="onPeriodicTypeConfirm"
|
||||||
@@ -217,7 +270,12 @@
|
|||||||
</van-popup>
|
</van-popup>
|
||||||
|
|
||||||
<!-- 星期选择器 -->
|
<!-- 星期选择器 -->
|
||||||
<van-popup v-model:show="showWeekdaysPicker" position="bottom" round teleport="body">
|
<van-popup
|
||||||
|
v-model:show="showWeekdaysPicker"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
teleport="body"
|
||||||
|
>
|
||||||
<van-picker
|
<van-picker
|
||||||
:columns="weekdaysColumns"
|
:columns="weekdaysColumns"
|
||||||
@confirm="onWeekdaysConfirm"
|
@confirm="onWeekdaysConfirm"
|
||||||
@@ -226,7 +284,12 @@
|
|||||||
</van-popup>
|
</van-popup>
|
||||||
|
|
||||||
<!-- 日期选择器 -->
|
<!-- 日期选择器 -->
|
||||||
<van-popup v-model:show="showMonthDaysPicker" position="bottom" round teleport="body">
|
<van-popup
|
||||||
|
v-model:show="showMonthDaysPicker"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
teleport="body"
|
||||||
|
>
|
||||||
<van-picker
|
<van-picker
|
||||||
:columns="monthDaysColumns"
|
:columns="monthDaysColumns"
|
||||||
@confirm="onMonthDaysConfirm"
|
@confirm="onMonthDaysConfirm"
|
||||||
@@ -243,7 +306,9 @@ import { showToast, showConfirmDialog } from 'vant'
|
|||||||
import {
|
import {
|
||||||
getPeriodicList,
|
getPeriodicList,
|
||||||
deletePeriodic as deletePeriodicApi,
|
deletePeriodic as deletePeriodicApi,
|
||||||
togglePeriodicEnabled
|
togglePeriodicEnabled,
|
||||||
|
createPeriodic,
|
||||||
|
updatePeriodic
|
||||||
} from '@/api/transactionPeriodic'
|
} from '@/api/transactionPeriodic'
|
||||||
import PopupContainer from '@/components/PopupContainer.vue'
|
import PopupContainer from '@/components/PopupContainer.vue'
|
||||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||||
@@ -375,7 +440,11 @@ const onLoad = () => {
|
|||||||
|
|
||||||
// 返回上一页
|
// 返回上一页
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
|
if (window.history.length > 1) {
|
||||||
router.back()
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取周期类型文本
|
// 获取周期类型文本
|
||||||
@@ -392,18 +461,20 @@ const getPeriodicTypeText = (item) => {
|
|||||||
|
|
||||||
if (item.periodicConfig) {
|
if (item.periodicConfig) {
|
||||||
switch (item.periodicType) {
|
switch (item.periodicType) {
|
||||||
case 1: // 每周
|
case 1: {
|
||||||
{
|
// 每周
|
||||||
const weekdays = item.periodicConfig.split(',').map(
|
const weekdays = item.periodicConfig
|
||||||
d => {
|
.split(',')
|
||||||
|
.map((d) => {
|
||||||
const dayMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
const dayMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
return dayMap[parseInt(d)] || ''
|
return dayMap[parseInt(d)] || ''
|
||||||
}).join('、')
|
})
|
||||||
|
.join('、')
|
||||||
text += ` (${weekdays})`
|
text += ` (${weekdays})`
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 2: // 每月
|
case 2: {
|
||||||
{
|
// 每月
|
||||||
const days = item.periodicConfig.split(',').join('、')
|
const days = item.periodicConfig.split(',').join('、')
|
||||||
text += ` (${days}日)`
|
text += ` (${days}日)`
|
||||||
break
|
break
|
||||||
@@ -433,23 +504,25 @@ const editPeriodic = (item) => {
|
|||||||
form.id = item.id
|
form.id = item.id
|
||||||
form.reason = item.reason
|
form.reason = item.reason
|
||||||
form.amount = item.amount.toString()
|
form.amount = item.amount.toString()
|
||||||
form.type = item.type
|
form.type = parseInt(item.type)
|
||||||
form.classify = item.classify
|
form.classify = item.classify
|
||||||
form.periodicType = item.periodicType
|
form.periodicType = parseInt(item.periodicType)
|
||||||
form.periodicTypeText = periodicTypeColumns.find(t => t.value === item.periodicType)?.text || ''
|
form.periodicTypeText = periodicTypeColumns.find((t) => t.value === parseInt(item.periodicType))?.text || ''
|
||||||
|
|
||||||
// 解析周期配置
|
// 解析周期配置
|
||||||
if (item.periodicConfig) {
|
if (item.periodicConfig) {
|
||||||
switch (item.periodicType) {
|
switch (item.periodicType) {
|
||||||
case 1: // 每周
|
case 1: // 每周
|
||||||
form.weekdays = item.periodicConfig.split(',').map(d => parseInt(d))
|
form.weekdays = item.periodicConfig.split(',').map((d) => parseInt(d))
|
||||||
form.weekdaysText = form.weekdays.map(d => {
|
form.weekdaysText = form.weekdays
|
||||||
return weekdaysColumns.find(w => w.value === d)?.text || ''
|
.map((d) => {
|
||||||
}).join('、')
|
return weekdaysColumns.find((w) => w.value === d)?.text || ''
|
||||||
|
})
|
||||||
|
.join('、')
|
||||||
break
|
break
|
||||||
case 2: // 每月
|
case 2: // 每月
|
||||||
form.monthDays = item.periodicConfig.split(',').map(d => parseInt(d))
|
form.monthDays = item.periodicConfig.split(',').map((d) => parseInt(d))
|
||||||
form.monthDaysText = form.monthDays.map(d => `${d}日`).join('、')
|
form.monthDaysText = form.monthDays.map((d) => `${d}日`).join('、')
|
||||||
break
|
break
|
||||||
case 3: // 每季度
|
case 3: // 每季度
|
||||||
form.quarterDay = item.periodicConfig
|
form.quarterDay = item.periodicConfig
|
||||||
@@ -468,7 +541,7 @@ const deletePeriodic = async (item) => {
|
|||||||
try {
|
try {
|
||||||
await showConfirmDialog({
|
await showConfirmDialog({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
message: '确定要删除这条周期性账单吗?',
|
message: '确定要删除这条周期性账单吗?'
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await deletePeriodicApi(item.id)
|
const response = await deletePeriodicApi(item.id)
|
||||||
@@ -493,7 +566,7 @@ const toggleEnabled = async (id, enabled) => {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
showToast(enabled ? '已启用' : '已禁用')
|
showToast(enabled ? '已启用' : '已禁用')
|
||||||
// 更新本地数据
|
// 更新本地数据
|
||||||
const item = periodicList.value.find(p => p.id === id)
|
const item = periodicList.value.find((p) => p.id === id)
|
||||||
if (item) {
|
if (item) {
|
||||||
item.isEnabled = enabled
|
item.isEnabled = enabled
|
||||||
}
|
}
|
||||||
@@ -510,7 +583,9 @@ const toggleEnabled = async (id, enabled) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDateTime = (date) => {
|
const formatDateTime = (date) => {
|
||||||
if (!date) return ''
|
if (!date) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||||
}
|
}
|
||||||
@@ -558,6 +633,104 @@ const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
|
|||||||
showMonthDaysPicker.value = false
|
showMonthDaysPicker.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const submit = async () => {
|
||||||
|
// 验证基本字段
|
||||||
|
if (!form.reason.trim()) {
|
||||||
|
showToast('请输入摘要')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.amount) {
|
||||||
|
showToast('请输入金额')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.amount < 0) {
|
||||||
|
showToast('金额必须大于0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.classify) {
|
||||||
|
showToast('请选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证周期配置
|
||||||
|
if (!form.periodicTypeText) {
|
||||||
|
showToast('请选择周期类型')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let periodicConfig = ''
|
||||||
|
switch (form.periodicType) {
|
||||||
|
case 0: // 每天
|
||||||
|
periodicConfig = ''
|
||||||
|
break
|
||||||
|
case 1: // 每周
|
||||||
|
if (form.weekdays.length === 0) {
|
||||||
|
showToast('请选择星期几')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
periodicConfig = form.weekdays.join(',')
|
||||||
|
break
|
||||||
|
case 2: // 每月
|
||||||
|
if (form.monthDays.length === 0) {
|
||||||
|
showToast('请选择日期')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
periodicConfig = form.monthDays.join(',')
|
||||||
|
break
|
||||||
|
case 3: // 每季度
|
||||||
|
if (!form.quarterDay) {
|
||||||
|
showToast('请输入季度开始后第几天')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
periodicConfig = form.quarterDay.toString()
|
||||||
|
break
|
||||||
|
case 4: // 每年
|
||||||
|
if (!form.yearDay) {
|
||||||
|
showToast('请输入年开始后第几天')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
periodicConfig = form.yearDay.toString()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const requestData = {
|
||||||
|
periodicType: parseInt(form.periodicType),
|
||||||
|
periodicConfig: periodicConfig,
|
||||||
|
amount: parseFloat(form.amount),
|
||||||
|
type: parseInt(form.type),
|
||||||
|
classify: form.classify,
|
||||||
|
reason: form.reason.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
let response
|
||||||
|
if (isEdit.value) {
|
||||||
|
// 更新
|
||||||
|
requestData.id = form.id
|
||||||
|
requestData.isEnabled = true // Update API 需要此字段
|
||||||
|
response = await updatePeriodic(requestData)
|
||||||
|
} else {
|
||||||
|
// 创建
|
||||||
|
response = await createPeriodic(requestData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showToast(isEdit.value ? '更新成功' : '添加成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
resetForm()
|
||||||
|
loadData(true)
|
||||||
|
} else {
|
||||||
|
showToast(response.message || (isEdit.value ? '更新失败' : '添加失败'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('提交出错:', error)
|
||||||
|
showToast(isEdit.value ? '更新出错' : '添加出错')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -596,4 +769,7 @@ const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.van-nav-bar) {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex">
|
<div class="page-container-flex">
|
||||||
<van-nav-bar title="定时任务" left-arrow placeholder @click-left="onClickLeft" />
|
<van-nav-bar
|
||||||
|
title="定时任务"
|
||||||
|
left-arrow
|
||||||
|
placeholder
|
||||||
|
@click-left="onClickLeft"
|
||||||
|
/>
|
||||||
<div class="scroll-content">
|
<div class="scroll-content">
|
||||||
<van-pull-refresh v-model="loading" @refresh="fetchTasks">
|
<van-pull-refresh
|
||||||
<div v-for="task in tasks" :key="task.name" class="task-card">
|
v-model="loading"
|
||||||
|
@refresh="fetchTasks"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.name"
|
||||||
|
class="task-card"
|
||||||
|
>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell :title="task.jobDescription" :label="task.triggerDescription || task.name">
|
<van-cell
|
||||||
|
:title="task.jobDescription"
|
||||||
|
:label="task.triggerDescription || task.name"
|
||||||
|
>
|
||||||
<template #value>
|
<template #value>
|
||||||
<van-tag :type="task.status === 'Paused' ? 'warning' : 'success'">
|
<van-tag :type="task.status === 'Paused' ? 'warning' : 'success'">
|
||||||
{{ task.status === 'Paused' ? '已暂停' : '已启动' }}
|
{{ task.status === 'Paused' ? '已暂停' : '已启动' }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
<van-cell title="任务标识" :value="task.name" />
|
<van-cell
|
||||||
<van-cell title="下次执行" :value="task.nextRunTime || '无'" />
|
title="任务标识"
|
||||||
|
:value="task.name"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="下次执行"
|
||||||
|
:value="task.nextRunTime || '无'"
|
||||||
|
/>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<van-row gutter="10">
|
<van-row gutter="10">
|
||||||
<van-col span="12">
|
<van-col span="12">
|
||||||
@@ -55,10 +76,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</van-pull-refresh>
|
</van-pull-refresh>
|
||||||
|
|
||||||
<van-empty v-if="tasks.length === 0 && !loading" description="无定时任务" />
|
<van-empty
|
||||||
|
v-if="tasks.length === 0 && !loading"
|
||||||
|
description="无定时任务"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 底部安全距离 -->
|
<!-- 底部安全距离 -->
|
||||||
<div style="height: calc(20px + env(safe-area-inset-bottom, 0px))"></div>
|
<div style="height: calc(20px + env(safe-area-inset-bottom, 0px))" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -95,14 +119,18 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onClickLeft = () => {
|
const onClickLeft = () => {
|
||||||
|
if (window.history.length > 1) {
|
||||||
router.back()
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExecute = async (task) => {
|
const handleExecute = async (task) => {
|
||||||
try {
|
try {
|
||||||
await showConfirmDialog({
|
await showConfirmDialog({
|
||||||
title: '确认执行',
|
title: '确认执行',
|
||||||
message: `确定要立即执行"${task.jobDescription}"吗?`,
|
message: `确定要立即执行"${task.jobDescription}"吗?`
|
||||||
})
|
})
|
||||||
|
|
||||||
showLoadingToast({
|
showLoadingToast({
|
||||||
@@ -132,7 +160,7 @@ const handlePause = async (task) => {
|
|||||||
try {
|
try {
|
||||||
await showConfirmDialog({
|
await showConfirmDialog({
|
||||||
title: '确认暂停',
|
title: '确认暂停',
|
||||||
message: `确定要暂停"${task.jobDescription}"吗?`,
|
message: `确定要暂停"${task.jobDescription}"吗?`
|
||||||
})
|
})
|
||||||
|
|
||||||
const { success, message } = await pauseJob(task.name)
|
const { success, message } = await pauseJob(task.name)
|
||||||
@@ -179,4 +207,9 @@ const handleResume = async (task) => {
|
|||||||
.scroll-content {
|
.scroll-content {
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 设置页面容器背景色 */
|
||||||
|
:deep(.van-nav-bar) {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,59 +1,137 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex">
|
<div class="page-container-flex">
|
||||||
<van-nav-bar title="设置" placeholder/>
|
<van-nav-bar
|
||||||
|
title="设置"
|
||||||
|
placeholder
|
||||||
|
/>
|
||||||
<div class="scroll-content">
|
<div class="scroll-content">
|
||||||
<div class="detail-header" style="padding-bottom: 5px;">
|
<div
|
||||||
|
class="detail-header"
|
||||||
|
style="padding-bottom: 5px"
|
||||||
|
>
|
||||||
<p>账单</p>
|
<p>账单</p>
|
||||||
</div>
|
</div>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="从支付宝导入" is-link @click="handleImportClick('Alipay')" />
|
<van-cell
|
||||||
<van-cell title="从微信导入" is-link @click="handleImportClick('WeChat')" />
|
title="从支付宝导入"
|
||||||
<van-cell title="周期记录" is-link @click="handlePeriodicRecord" />
|
is-link
|
||||||
|
@click="handleImportClick('Alipay')"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="从微信导入"
|
||||||
|
is-link
|
||||||
|
@click="handleImportClick('WeChat')"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="周期记录"
|
||||||
|
is-link
|
||||||
|
@click="handlePeriodicRecord"
|
||||||
|
/>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<!-- 隐藏的文件选择器 -->
|
<!-- 隐藏的文件选择器 -->
|
||||||
<input ref="fileInputRef" type="file" accept=".csv,.xlsx,.xls" style="display: none" @change="handleFileChange" />
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.xlsx,.xls"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleFileChange"
|
||||||
|
>
|
||||||
|
|
||||||
<div class="detail-header" style="padding-bottom: 5px;">
|
<div
|
||||||
|
class="detail-header"
|
||||||
|
style="padding-bottom: 5px"
|
||||||
|
>
|
||||||
<p>分类</p>
|
<p>分类</p>
|
||||||
</div>
|
</div>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="待确认分类" is-link @click="handleUnconfirmedClassification" />
|
<van-cell
|
||||||
<van-cell title="编辑分类" is-link @click="handleEditClassification" />
|
title="待确认分类"
|
||||||
<van-cell title="批量分类" is-link @click="handleBatchClassification" />
|
is-link
|
||||||
<van-cell title="智能分类" is-link @click="handleSmartClassification" />
|
@click="handleUnconfirmedClassification"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="编辑分类"
|
||||||
|
is-link
|
||||||
|
@click="handleEditClassification"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="批量分类"
|
||||||
|
is-link
|
||||||
|
@click="handleBatchClassification"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="智能分类"
|
||||||
|
is-link
|
||||||
|
@click="handleSmartClassification"
|
||||||
|
/>
|
||||||
<!-- <van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" /> -->
|
<!-- <van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" /> -->
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<div class="detail-header" style="padding-bottom: 5px;">
|
<div
|
||||||
|
class="detail-header"
|
||||||
|
style="padding-bottom: 5px"
|
||||||
|
>
|
||||||
<p>通知</p>
|
<p>通知</p>
|
||||||
</div>
|
</div>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="开启消息通知">
|
<van-cell title="开启消息通知">
|
||||||
<template #right-icon>
|
<template #right-icon>
|
||||||
<van-switch v-model="notificationEnabled" size="24" :loading="notificationLoading" @change="handleNotificationToggle" />
|
<van-switch
|
||||||
|
v-model="notificationEnabled"
|
||||||
|
size="24"
|
||||||
|
:loading="notificationLoading"
|
||||||
|
@change="handleNotificationToggle"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
<van-cell v-if="notificationEnabled" title="测试通知" is-link @click="handleTestNotification" />
|
<van-cell
|
||||||
|
v-if="notificationEnabled"
|
||||||
|
title="测试通知"
|
||||||
|
is-link
|
||||||
|
@click="handleTestNotification"
|
||||||
|
/>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<div class="detail-header" style="padding-bottom: 5px;">
|
<div
|
||||||
|
class="detail-header"
|
||||||
|
style="padding-bottom: 5px"
|
||||||
|
>
|
||||||
<p>开发者</p>
|
<p>开发者</p>
|
||||||
</div>
|
</div>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="查看日志" is-link @click="handleLogView" />
|
<van-cell
|
||||||
<van-cell title="清除缓存" is-link @click="handleReloadFromNetwork" />
|
title="查看日志"
|
||||||
<van-cell title="定时任务" is-link @click="handleScheduledTasks" />
|
is-link
|
||||||
|
@click="handleLogView"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="清除缓存"
|
||||||
|
is-link
|
||||||
|
@click="handleReloadFromNetwork"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="定时任务"
|
||||||
|
is-link
|
||||||
|
@click="handleScheduledTasks"
|
||||||
|
/>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<div class="detail-header" style="padding-bottom: 5px;">
|
<div
|
||||||
|
class="detail-header"
|
||||||
|
style="padding-bottom: 5px"
|
||||||
|
>
|
||||||
<p>账户</p>
|
<p>账户</p>
|
||||||
</div>
|
</div>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="退出登录" is-link @click="handleLogout" />
|
<van-cell
|
||||||
|
title="退出登录"
|
||||||
|
is-link
|
||||||
|
@click="handleLogout"
|
||||||
|
/>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
<!-- 底部安全距离 -->
|
<!-- 底部安全距离 -->
|
||||||
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div>
|
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -82,15 +160,15 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String) {
|
function urlBase64ToUint8Array (base64String) {
|
||||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
const rawData = window.atob(base64);
|
const rawData = window.atob(base64)
|
||||||
const outputArray = new Uint8Array(rawData.length);
|
const outputArray = new Uint8Array(rawData.length)
|
||||||
for (let i = 0; i < rawData.length; ++i) {
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
outputArray[i] = rawData.charCodeAt(i);
|
outputArray[i] = rawData.charCodeAt(i)
|
||||||
}
|
}
|
||||||
return outputArray;
|
return outputArray
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNotificationToggle = async (checked) => {
|
const handleNotificationToggle = async (checked) => {
|
||||||
@@ -113,7 +191,7 @@ const handleNotificationToggle = async (checked) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let { success, data, message } = await getVapidPublicKey()
|
const { success, data, message } = await getVapidPublicKey()
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
throw new Error(message || '获取 VAPID 公钥失败')
|
throw new Error(message || '获取 VAPID 公钥失败')
|
||||||
@@ -184,7 +262,11 @@ const handleFileChange = async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件类型
|
// 验证文件类型
|
||||||
const validTypes = ['text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
|
const validTypes = [
|
||||||
|
'text/csv',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
]
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
showToast('请选择 CSV 或 Excel 文件')
|
showToast('请选择 CSV 或 Excel 文件')
|
||||||
return
|
return
|
||||||
@@ -218,8 +300,7 @@ const handleFileChange = async (event) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上传失败:', error)
|
console.error('上传失败:', error)
|
||||||
showToast('上传失败: ' + (error.message || '未知错误'))
|
showToast('上传失败: ' + (error.message || '未知错误'))
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
closeToast()
|
closeToast()
|
||||||
// 清空文件输入,允许重复选择同一文件
|
// 清空文件输入,允许重复选择同一文件
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
@@ -249,7 +330,7 @@ const handleLogout = async () => {
|
|||||||
try {
|
try {
|
||||||
await showConfirmDialog({
|
await showConfirmDialog({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
message: '确定要退出登录吗?',
|
message: '确定要退出登录吗?'
|
||||||
})
|
})
|
||||||
|
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
@@ -276,7 +357,7 @@ const handleReloadFromNetwork = async () => {
|
|||||||
try {
|
try {
|
||||||
await showConfirmDialog({
|
await showConfirmDialog({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
message: '确定要刷新网络吗?此操作不可撤销。',
|
message: '确定要刷新网络吗?此操作不可撤销。'
|
||||||
})
|
})
|
||||||
|
|
||||||
// PWA程序强制页面更新到最新版本
|
// PWA程序强制页面更新到最新版本
|
||||||
@@ -300,34 +381,20 @@ const handleReloadFromNetwork = async () => {
|
|||||||
const handleScheduledTasks = () => {
|
const handleScheduledTasks = () => {
|
||||||
router.push({ name: 'scheduled-tasks' })
|
router.push({ name: 'scheduled-tasks' })
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 页面背景色 */
|
/* 页面背景色 */
|
||||||
:deep(body) {
|
:deep(body) {
|
||||||
background-color: #f5f5f5;
|
background-color: var(--van-background);
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:deep(body) {
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 增加卡片对比度 */
|
/* 增加卡片对比度 */
|
||||||
:deep(.van-cell-group--inset) {
|
:deep(.van-cell-group--inset) {
|
||||||
background-color: #ffffff;
|
background-color: var(--van-background-2);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:deep(.van-cell-group--inset) {
|
|
||||||
background-color: #2c2c2c;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
.detail-header {
|
||||||
padding: 16px 16px 5px 16px;
|
padding: 16px 16px 5px 16px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
@@ -336,7 +403,7 @@ const handleScheduledTasks = () => {
|
|||||||
.detail-header p {
|
.detail-header p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #969799;
|
color: var(--van-text-color-2);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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