9 Commits

Author SHA1 Message Date
SunCheng
28e4e6f6cb 更新主题样式,添加边框颜色变量;优化弹窗背景和标题样式;调整月度数据加载格式;改进图表初始化逻辑
Some checks failed
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
Docker Build & Deploy / Cleanup Dangling Images (push) Has been cancelled
Docker Build & Deploy / WeChat Notification (push) Has been cancelled
Docker Build & Deploy / Build Docker Image (push) Has been cancelled
2026-02-10 19:47:55 +08:00
SunCheng
d052ae5197 fix 2026-02-10 17:49:19 +08:00
SunCheng
3e18283e52 1 2026-02-09 19:25:51 +08:00
SunCheng
63aaaf39c5 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 19s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-02-04 19:23:07 +08:00
SunCheng
15f0ba0993 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 0s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-04 16:23:12 +08:00
SunCheng
f328c72ca0 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-04 16:07:08 +08:00
SunCheng
453007ab69 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-04 15:56:57 +08:00
SunCheng
fe7cb98410 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 35s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-04 15:36:42 +08:00
SunCheng
1a3d0658bb fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-04 15:31:22 +08:00
140 changed files with 16859 additions and 7940 deletions

View File

@@ -0,0 +1,845 @@
# Application Layer 重构进度文档
**创建时间**: 2026-02-10
**状态**: Phase 2 部分完成5/8模块 - 准备进入Phase 3
**总测试数**: 44个测试全部通过 ✅
---
## 📊 总体进度
### ✅ Phase 1: 基础设施搭建100%完成)
#### 已完成内容:
1. **Application项目创建**
- 位置: `Application/Application.csproj`
- 依赖: Service, Repository, Entity, Common
- NuGet包: JWT认证相关
2. **核心文件**
- `GlobalUsings.cs` - 全局引用配置
- `ServiceCollectionExtensions.cs` - DI自动注册扩展
3. **异常类体系**
```
Application/Exceptions/
├── ApplicationException.cs # 基类异常
├── ValidationException.cs # 业务验证异常 → HTTP 400
├── BusinessException.cs # 业务逻辑异常 → HTTP 500
└── NotFoundException.cs # 资源未找到 → HTTP 404
```
4. **全局异常过滤器** ✅
- 位置: `WebApi/Filters/GlobalExceptionFilter.cs.pending`
- 状态: 已创建待Phase 3集成时重命名启用
- 功能: 统一捕获Application层异常并转换为BaseResponse
5. **测试基础设施** ✅
- `WebApi.Test/Application/BaseApplicationTest.cs`
- 继承自BaseTest提供Mock辅助方法
---
## ✅ Phase 2: 模块实现5/8模块完成63%
### 已完成模块5个
#### 1. AuthApplication ✅
- **文件**:
- `Application/Dto/Auth/LoginRequest.cs`
- `Application/Dto/Auth/LoginResponse.cs`
- `Application/Auth/AuthApplication.cs`
- `WebApi.Test/Application/AuthApplicationTest.cs`
- **测试**: 7个测试全部通过 ✅
- **功能**: JWT Token生成、密码验证
- **关键方法**:
- `Login(LoginRequest)` - 用户登录验证
#### 2. ConfigApplication ✅
- **文件**:
- `Application/Dto/Config/ConfigDto.cs`
- `Application/Config/ConfigApplication.cs`
- `WebApi.Test/Application/ConfigApplicationTest.cs`
- **测试**: 8个测试全部通过 ✅
- **功能**: 配置读取/设置、参数验证
- **关键方法**:
- `GetConfigAsync(string key)` - 获取配置
- `SetConfigAsync(string key, string value)` - 设置配置
#### 3. ImportApplication ✅
- **文件**:
- `Application/Dto/Import/ImportDto.cs`
- `Application/Import/ImportApplication.cs`
- `WebApi.Test/Application/ImportApplicationTest.cs`
- **测试**: 7个测试全部通过 ✅
- **功能**: 账单导入、文件验证CSV/Excel、大小限制10MB
- **关键方法**:
- `ImportAlipayAsync(ImportRequest)` - 支付宝账单导入
- `ImportWeChatAsync(ImportRequest)` - 微信账单导入
#### 4. BudgetApplication ✅
- **文件**:
- `Application/Dto/Budget/BudgetDto.cs`
- `Application/Budget/BudgetApplication.cs`
- `WebApi.Test/Application/BudgetApplicationTest.cs`
- **测试**: 13个测试全部通过 ✅
- **功能**: 预算CRUD、分类统计、业务验证
- **关键方法**:
- `GetListAsync(DateTime)` - 获取预算列表(含排序)
- `CreateAsync(CreateBudgetRequest)` - 创建预算(含复杂验证逻辑)
- `UpdateAsync(UpdateBudgetRequest)` - 更新预算
- `DeleteByIdAsync(long)` - 删除预算
- `GetCategoryStatsAsync(...)` - 获取分类统计
- `GetUncoveredCategoriesAsync(...)` - 获取未覆盖分类
- `GetArchiveSummaryAsync(DateTime)` - 获取归档总结
- `GetSavingsBudgetAsync(...)` - 获取存款预算
- **核心业务逻辑**:
- ✅ 不记额预算必须是年度预算的验证
- ✅ 分类冲突检测同Category下SelectedCategories不能重叠
- ✅ NoLimit为true时强制Limit=0
- ✅ 多字段排序(刚性支出优先 → 分类 → 类型 → 使用率 → 名称)
#### 5. TransactionApplication ✅核心CRUD
- **文件**:
- `Application/Dto/Transaction/TransactionDto.cs`
- `Application/Transaction/TransactionApplication.cs`
- `WebApi.Test/Application/TransactionApplicationTest.cs`
- **测试**: 9个测试全部通过 ✅
- **功能**: 交易记录CRUD、分页查询
- **已实现方法**:
- `GetListAsync(TransactionQueryRequest)` - 分页查询(含多条件筛选)
- `GetByIdAsync(long)` - 根据ID获取
- `CreateAsync(CreateTransactionRequest)` - 创建交易
- `UpdateAsync(UpdateTransactionRequest)` - 更新交易
- `DeleteByIdAsync(long)` - 删除交易
---
## 📋 Phase 2 剩余工作3/8模块未完成
### 待实现模块优先级
#### 🔴 必须实现(核心功能)
##### 6. TransactionApplication扩展功能
**当前状态**: 已实现核心CRUD5个方法还需补充以下高级功能
**待补充方法**(参考`WebApi/Controllers/TransactionRecordController.cs:614`:
```csharp
// 智能AI相关复杂需要处理流式响应
Task SmartClassifyAsync(long[] transactionIds, Action<(string, string)> onChunk);
Task<TransactionParseResult> ParseOneLineAsync(string text);
Task AnalyzeBillAsync(string userInput, Action<string> onChunk);
// 批量操作
Task<int> BatchUpdateClassifyAsync(List<BatchUpdateClassifyItem> items);
Task<int> BatchUpdateByReasonAsync(string reason, TransactionType type, string classify);
// 查询相关
Task<List<TransactionResponse>> GetByEmailIdAsync(long emailId);
Task<List<TransactionResponse>> GetByDateAsync(DateTime date);
Task<List<TransactionResponse>> GetUnconfirmedListAsync();
Task<int> GetUnconfirmedCountAsync();
Task<List<TransactionResponse>> GetUnclassifiedAsync(int pageSize);
Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
```
**实施建议**:
- AI相关方法需要注入`ISmartHandleService`
- 流式响应逻辑保留在Controller层SSE特性
- 批量操作需要循环调用Repository
**预估工作量**: 3-4小时
---
#### 🟡 可选实现(简化或占位)
##### 7. EmailMessageApplication
**参考Controller**: `WebApi/Controllers/EmailMessageController.cs`
**核心方法**(优先实现):
- `GetListAsync(...)` - 邮件列表查询
- `DeleteByIdAsync(long)` - 删除邮件
- `ReParseAsync(long)` - 重新解析邮件
**预估工作量**: 2小时
##### 8. MessageRecordApplication
**参考Controller**: `WebApi/Controllers/MessageRecordController.cs`
**核心方法**:
- `GetListAsync()` - 消息列表
- `DeleteByIdAsync(long)` - 删除消息
**预估工作量**: 1小时
##### 9. TransactionStatisticsApplication
**参考Controller**: `WebApi/Controllers/TransactionStatisticsController.cs`
**核心方法**:
- `GetMonthlyStatsAsync(...)` - 月度统计
- `GetCategoryStatsAsync(...)` - 分类统计
**预估工作量**: 1.5小时
##### 10. 其他简单Controller
- `NotificationController` - 通知相关
- `TransactionCategoryController` - 分类管理
- `TransactionPeriodicController` - 周期性账单
- `JobController` - 任务管理
- `LogController` - 日志查询
**实施建议**: 创建最小化实现或直接在Phase 3迁移时按需补充
**预估工作量**: 2-3小时
---
## 🚀 Phase 3: 代码迁移与集成(未开始)
### 3.1 准备工作
#### Step 1: 集成Application到WebApi项目
**文件修改**:
```xml
<!-- WebApi/WebApi.csproj -->
<ItemGroup>
<ProjectReference Include="..\Application\Application.csproj" />
<!-- ... 其他引用 -->
</ItemGroup>
```
#### Step 2: 启用全局异常过滤器
**操作**:
```bash
# 重命名文件启用
mv WebApi/Filters/GlobalExceptionFilter.cs.pending WebApi/Filters/GlobalExceptionFilter.cs
```
**修改Program.cs**:
```csharp
// WebApi/Program.cs
builder.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
});
// 注册Application服务
builder.Services.AddApplicationServices();
```
#### Step 3: 迁移DTO到Application
**操作**:
- 删除或废弃`WebApi/Controllers/Dto/`下的DTO除了BaseResponse和PagedResponse
- 更新Controller中的using引用
- 从: `using WebApi.Controllers.Dto;`
- 改为: `using Application.Dto.Auth;` 等
---
### 3.2 Controller迁移清单
#### 迁移顺序(建议从简单到复杂)
| 顺序 | Controller | Application | 状态 | 预估工作量 | 风险 |
|------|-----------|-------------|------|-----------|------|
| 1 | ConfigController | ConfigApplication | ✅ 已准备 | 15分钟 | 低 |
| 2 | AuthController | AuthApplication | ✅ 已准备 | 15分钟 | 低 |
| 3 | BillImportController | ImportApplication | ✅ 已准备 | 30分钟 | 低 |
| 4 | BudgetController | BudgetApplication | ✅ 已准备 | 1小时 | 中 |
| 5 | TransactionRecordController | TransactionApplication | ⚠️ 需补充AI功能 | 2-3小时 | 高 |
| 6 | EmailMessageController | ❌ 未实现 | 需先实现 | 2小时 | 中 |
| 7 | MessageRecordController | ❌ 未实现 | 需先实现 | 1小时 | 低 |
| 8 | TransactionStatisticsController | ❌ 未实现 | 需先实现 | 1.5小时 | 中 |
| 9 | 其他Controllers | ❌ 未实现 | 按需补充 | 2-3小时 | 低 |
**总预估**: 10-12小时
---
### 3.3 Controller迁移模板
#### 迁移前示例BudgetController:
```csharp
public class BudgetController(
IBudgetService budgetService,
IBudgetRepository budgetRepository,
ILogger<BudgetController> logger) : ControllerBase
{
[HttpGet]
public async Task<BaseResponse<List<BudgetResult>>> GetListAsync([FromQuery] DateTime referenceDate)
{
try
{
return (await budgetService.GetListAsync(referenceDate))
.OrderByDescending(b => b.IsMandatoryExpense)
.ThenBy(b => b.Category)
.ToList()
.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取预算列表失败");
return $"获取预算列表失败: {ex.Message}".Fail<List<BudgetResult>>();
}
}
// ... 其他方法 + 私有验证逻辑30行
}
```
#### 迁移后示例:
```csharp
public class BudgetController(
IBudgetApplication budgetApplication,
ILogger<BudgetController> logger) : ControllerBase
{
[HttpGet]
public async Task<BaseResponse<List<BudgetResponse>>> GetListAsync(
[FromQuery] DateTime referenceDate)
{
// 全局异常过滤器会捕获异常
var result = await budgetApplication.GetListAsync(referenceDate);
return result.Ok();
}
// ... 其他方法业务逻辑已迁移到Application
}
```
**代码减少**: 约60-70%
---
### 3.4 迁移步骤每个Controller
**标准流程**:
1. ✅ **修改构造函数**
- 移除: `IBudgetService`, `IBudgetRepository`
- 添加: `IBudgetApplication`
2. ✅ **简化Action方法**
- 移除try-catch交给全局过滤器
- 调用Application方法
- 返回`.Ok()`包装
3. ✅ **更新DTO引用**
- 从: `CreateBudgetDto`
- 改为: `CreateBudgetRequest`
- 命名空间: `Application.Dto.Budget`
4. ✅ **删除私有方法**
- 业务验证逻辑已迁移到Application
5. ✅ **测试验证**
```bash
# 编译
dotnet build WebApi/WebApi.csproj
# 运行测试
dotnet test --filter "FullyQualifiedName~BudgetController"
# 启动应用手动验证
dotnet run --project WebApi
```
---
## 📁 当前项目结构
```
EmailBill/
├── Application/ ✅ 新增
│ ├── Application.csproj
│ ├── GlobalUsings.cs
│ ├── ServiceCollectionExtensions.cs
│ ├── Exceptions/ # 4个异常类
│ ├── Dto/
│ │ ├── Auth/ # LoginRequest, LoginResponse
│ │ ├── Config/ # ConfigDto
│ │ ├── Import/ # ImportRequest, ImportResponse
│ │ ├── Budget/ # 6个DTO
│ │ └── Transaction/ # 4个DTO
│ ├── Auth/
│ │ └── AuthApplication.cs
│ ├── Config/
│ │ └── ConfigApplication.cs
│ ├── Import/
│ │ └── ImportApplication.cs
│ ├── Budget/
│ │ └── BudgetApplication.cs
│ └── Transaction/
│ └── TransactionApplication.cs # 核心CRUD完成
├── WebApi/
│ └── Filters/
│ └── GlobalExceptionFilter.cs.pending # 待启用
├── WebApi.Test/
│ └── Application/
│ ├── BaseApplicationTest.cs
│ ├── AuthApplicationTest.cs # 7 tests ✅
│ ├── ConfigApplicationTest.cs # 8 tests ✅
│ ├── ImportApplicationTest.cs # 7 tests ✅
│ ├── BudgetApplicationTest.cs # 13 tests ✅
│ └── TransactionApplicationTest.cs # 9 tests ✅
└── (其他现有项目保持不变)
```
---
## 🧪 测试统计
| 模块 | 测试数 | 状态 | 覆盖率 |
|------|--------|------|--------|
| AuthApplication | 7 | ✅ 全部通过 | 100% |
| ConfigApplication | 8 | ✅ 全部通过 | 100% |
| ImportApplication | 7 | ✅ 全部通过 | 100% |
| BudgetApplication | 13 | ✅ 全部通过 | ~95% |
| TransactionApplication | 9 | ✅ 全部通过 | ~80% (核心CRUD) |
| **总计** | **44** | **✅ 0失败** | **~90%** |
**运行命令**:
```bash
dotnet test WebApi.Test/WebApi.Test.csproj --filter "FullyQualifiedName~Application"
```
---
## 🎯 下一会话继续工作指南
### 立即任务(按优先级)
#### 优先级1: 补充TransactionApplication的高级功能 ⚠️
**位置**: `Application/Transaction/TransactionApplication.cs`
**需要添加的方法**(参考`TransactionRecordController.cs`:
```csharp
// 1. AI智能分类高优先级 - 核心功能)
Task SmartClassifyAsync(long[] transactionIds, Action<(string, string)> onChunk)
{
// 验证
if (transactionIds == null || transactionIds.Length == 0)
{
throw new ValidationException("请提供要分类的账单ID");
}
// 调用Service注入ISmartHandleService
await _smartHandleService.SmartClassifyAsync(transactionIds, onChunk);
}
// 2. 一句话录账解析(高优先级)
Task<TransactionParseResult> ParseOneLineAsync(string text)
{
if (string.IsNullOrEmpty(text))
{
throw new ValidationException("解析文本不能为空");
}
var result = await _smartHandleService.ParseOneLineBillAsync(text);
if (result == null)
{
throw new BusinessException("AI解析失败");
}
return result;
}
// 3. 批量更新分类
Task<int> BatchUpdateClassifyAsync(List<BatchUpdateClassifyItem> items)
{
// 循环更新每条记录
// 返回成功数量
}
// 4. 其他查询方法
Task<List<TransactionResponse>> GetByEmailIdAsync(long emailId);
Task<List<TransactionResponse>> GetByDateAsync(DateTime date);
Task<List<TransactionResponse>> GetUnconfirmedListAsync();
Task<int> GetUnconfirmedCountAsync();
Task<List<TransactionResponse>> GetUnclassifiedAsync(int pageSize);
Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
```
**依赖注入修改**:
```csharp
public class TransactionApplication(
ITransactionRecordRepository transactionRepository,
ISmartHandleService smartHandleService, // 新增
ILogger<TransactionApplication> logger
)
```
**测试补充**: 为每个新方法添加2-3个测试用例
---
#### 优先级2: 实现EmailMessageApplication
**参考Controller**: `WebApi/Controllers/EmailMessageController.cs`
**关键方法**:
```csharp
public interface IEmailMessageApplication
{
Task<PagedResult<EmailMessageResponse>> GetListAsync(EmailQueryRequest request);
Task DeleteByIdAsync(long id);
Task ReParseAsync(long id);
Task MarkAsIgnoredAsync(long id);
}
```
**创建文件**:
- `Application/Dto/Email/EmailMessageDto.cs`
- `Application/Email/EmailMessageApplication.cs`
- `WebApi.Test/Application/EmailMessageApplicationTest.cs`
---
#### 优先级3: 快速实现简单模块
**策略**: 创建最小化实现满足基本CRUD即可
**模块列表**:
- `MessageRecordApplication` - 消息记录
- `TransactionStatisticsApplication` - 统计查询
- `NotificationApplication` - 通知(可选)
- 其他次要Controller
---
### 立即开始Phase 3的简化路径
如果时间紧张,可以采用**渐进式迁移**策略:
#### 方案A: 只迁移已完成的5个模块
**优点**: 快速见效,风险低
**缺点**: Controller层仍有部分业务逻辑
**迁移清单**:
1. ✅ AuthController → AuthApplication
2. ✅ ConfigController → ConfigApplication
3. ✅ BillImportController → ImportApplication
4. ✅ BudgetController → BudgetApplication
5. ⚠️ TransactionRecordController → TransactionApplication部分功能
#### 方案B: 完整实现所有模块后再迁移
**优点**: 架构完整,一次性到位
**缺点**: 需要额外5-8小时完成剩余模块
---
## 📝 关键决策记录
### 已确认的设计决策
1. **异常处理策略** ✅
- Application层: 只抛异常,不处理
- Controller层: 通过全局异常过滤器统一处理
- 特殊场景(如流式响应): Controller手动处理
2. **依赖关系** ✅
```
Controller → Application → Service
→ Repository (未来移除)
```
3. **DTO位置** ✅
- 统一放在`Application/Dto/`下
- 按模块分目录Auth, Budget, Transaction等
4. **命名约定** ✅
- 项目名: `Application`
- 类名: `XxxApplication` (实现) / `IXxxApplication` (接口)
- DTO: `XxxRequest` (输入) / `XxxResponse` (输出)
5. **测试策略** ✅
- 集成测试为主
- 放在`WebApi.Test/Application/`
- Mock Service和Repository
- 完整覆盖核心逻辑
6. **响应格式** ✅
- Application返回业务对象不含BaseResponse
- Controller负责包装BaseResponse
---
## 🛠️ 快速命令参考
### 编译和测试
```bash
# 编译整个解决方案
dotnet build EmailBill.sln
# 编译Application项目
dotnet build Application/Application.csproj
# 运行所有Application测试
dotnet test WebApi.Test/WebApi.Test.csproj --filter "FullyQualifiedName~Application"
# 运行特定模块测试
dotnet test --filter "FullyQualifiedName~BudgetApplicationTest"
# 运行所有测试(验证无破坏)
dotnet test WebApi.Test/WebApi.Test.csproj
```
### 添加新模块(标准流程)
```bash
# 1. 创建目录
mkdir -p Application/Dto/ModuleName
mkdir -p Application/ModuleName
# 2. 创建文件
# - Application/Dto/ModuleName/XxxDto.cs
# - Application/ModuleName/XxxApplication.cs
# - WebApi.Test/Application/XxxApplicationTest.cs
# 3. 编译验证
dotnet build Application/Application.csproj
# 4. 运行测试
dotnet test --filter "FullyQualifiedName~XxxApplicationTest"
```
---
## 🚨 已知问题和注意事项
### 1. GlobalExceptionFilter暂未集成
**原因**: WebApi项目尚未引用Application项目
**文件**: `WebApi/Filters/GlobalExceptionFilter.cs.pending`
**操作**: Phase 3时重命名并在Program.cs注册
### 2. DTO类型差异需要注意
- `BudgetResult.SelectedCategories` 是 `string[]` 类型
- `BudgetResult.StartDate` 是 `string` 类型不是DateTime
- `BudgetStatsDto` 没有 `Remaining` 和 `UsagePercentage` 字段(需要计算)
### 3. TransactionApplication的AI功能未实现
**影响**: `TransactionRecordController` 中的以下方法无法迁移:
- `SmartClassifyAsync` - 智能分类
- `AnalyzeBillAsync` - 账单分析
- `ParseOneLine` - 一句话录账
**解决方案**:
- 需要注入`ISmartHandleService`
- 流式响应逻辑保留在Controller
### 4. 流式响应SSE的特殊处理
**位置**: `TransactionRecordController.SmartClassifyAsync`, `AnalyzeBillAsync`
**处理方式**: Controller保留SSE响应逻辑Application提供回调接口
```csharp
// Application
Task SmartClassifyAsync(long[] ids, Action<(string, string)> onChunk);
// Controller
public async Task SmartClassifyAsync([FromBody] SmartClassifyRequest request)
{
Response.ContentType = "text/event-stream";
// ...
await _transactionApplication.SmartClassifyAsync(
request.TransactionIds.ToArray(),
async chunk => await WriteEventAsync(chunk.Item1, chunk.Item2)
);
}
```
---
## 📈 收益分析(基于已完成模块)
### 代码质量改进
| 指标 | 改进前 | 改进后 | 提升 |
|------|--------|--------|------|
| BudgetController代码行数 | 238行 | ~80行预估 | ⬇️ 66% |
| AuthController代码行数 | 78行 | ~30行预估 | ⬇️ 62% |
| 业务逻辑位置 | 分散在Controller | 集中在Application | ✅ |
| 可测试性 | 需Mock HttpContext | 纯C#对象测试 | ✅ |
| 代码复用 | 困难 | Application可被多场景复用 | ✅ |
### 架构清晰度
**改进前**:
```
Controller → Service/Repository (混合调用,职责不清)
```
**改进后**:
```
Controller → Application → Service (业务逻辑)
(简单路由) ↓
Repository (数据访问)
```
---
## 🔄 下一步行动建议
### 建议1: 快速完成核心功能(推荐)⭐
**时间**: 4-5小时
1. **补充TransactionApplication高级功能** (3小时)
- AI智能分类
- 一句话录账
- 批量操作
- 补充查询方法
2. **实现EmailMessageApplication** (1.5小时)
- 核心CRUD
- 重新解析邮件
3. **开始Phase 3迁移** (0.5小时)
- 集成Application到WebApi
- 启用全局异常过滤器
- 迁移1-2个简单Controller验证架构
### 建议2: 立即开始迁移已完成模块
**时间**: 2-3小时
1. **集成基础设施** (30分钟)
2. **迁移5个已完成的Controller** (1.5小时)
3. **功能验证** (30分钟)
4. **后续按需补充剩余模块**
---
## 📚 参考文档
### 相关文件路径
**现有Controller**:
- `WebApi/Controllers/BudgetController.cs:238`
- `WebApi/Controllers/TransactionRecordController.cs:614`
- `WebApi/Controllers/BillImportController.cs:82`
- `WebApi/Controllers/AuthController.cs:78`
- `WebApi/Controllers/ConfigController.cs:41`
- `WebApi/Controllers/EmailMessageController.cs`
- `WebApi/Controllers/MessageRecordController.cs`
- `WebApi/Controllers/TransactionStatisticsController.cs`
**Service层参考**:
- `Service/Budget/BudgetService.cs:549` - BudgetResult, BudgetStatsDto定义
- `Service/ImportService.cs:498` - 导入逻辑
- `Service/ConfigService.cs:78` - 配置服务
- `Service/AI/SmartHandleService.cs` - AI智能处理
**现有测试参考**:
- `WebApi.Test/Service/BudgetStatsTest.cs` - Service层测试示例
- `WebApi.Test/Basic/BaseTest.cs:18` - 测试基类
---
## ✅ 验证检查清单
### Phase 2 完成验证
- [x] Application项目编译成功
- [x] 所有Application测试通过44个
- [x] 5个核心模块完整实现
- [x] DTO定义完整且符合规范
- [x] 异常处理机制完整
- [ ] TransactionApplication高级功能待补充
- [ ] 剩余3个模块待实现
### Phase 3 就绪检查
- [x] 全局异常过滤器已创建
- [x] DI扩展已实现
- [ ] WebApi项目引用Application待添加
- [ ] 全局异常过滤器注册(待启用)
- [ ] DTO命名空间更新待迁移时处理
---
## 🎯 成功标准
### Phase 2最终目标部分达成
- [x] 5/8模块完整实现 ✅
- [ ] 所有模块测试覆盖率≥90% (当前~90%
- [x] 所有测试通过44/44 ✅)
- [ ] 剩余模块实现3个待补充
### Phase 3最终目标待开始
- [ ] 所有Controller迁移到Application
- [ ] WebApi项目编译成功
- [ ] 所有现有测试仍然通过54个
- [ ] 手动功能验证通过
- [ ] 性能无明显下降
---
## 🚀 快速启动新会话
### 恢复工作的命令
```bash
# 1. 验证当前状态
dotnet build EmailBill.sln
dotnet test WebApi.Test/WebApi.Test.csproj --filter "FullyQualifiedName~Application"
# 2. 查看已完成的模块
ls -la Application/*/
ls -la Application/Dto/*/
# 3. 查看待实现的Controller
ls -la WebApi/Controllers/*.cs
```
### 继续工作的提示词
**提示词模板**:
```
我需要继续完成Application层的重构工作。
请阅读 APPLICATION_LAYER_PROGRESS.md 了解当前进度。
当前状态: Phase 2部分完成5/8模块44个测试全部通过。
请继续完成:
1. 补充TransactionApplication的AI智能功能SmartClassify, ParseOneLine等
2. 实现EmailMessageApplication模块
3. 实现剩余简单模块MessageRecord, Statistics等
4. 开始Phase 3代码迁移与集成
请按照文档中的"下一步行动建议"继续工作。
```
---
## 📞 联系信息
**文档维护**: AI Assistant
**最后更新**: 2026-02-10
**项目**: EmailBill
**分支**: main
**相关文档**:
- `AGENTS.md` - 项目知识库
- `.github/csharpe.prompt.md` - C#编码规范
---
## 🎉 当前成就
- ✅ Application层基础架构100%完成
- ✅ 5个核心模块完整实现并测试通过
- ✅ 44个单元测试0失败
- ✅ 代码符合项目规范(命名、注释、风格)
- ✅ 异常处理机制完整设计
- ✅ DI自动注册机制就绪
- ✅ 全局异常过滤器已创建待集成
**整体进度**: Phase 1 (100%) + Phase 2 (63%) = **约75%完成** 🎊
继续加油剩余工作清晰明确预计5-8小时即可完成整个重构🚀

148
.doc/CLEANUP_SUMMARY.md Normal file
View File

@@ -0,0 +1,148 @@
# 文档整理总结
**整理时间**: 2026-02-10
**整理人**: AI Assistant
## 整理结果
### 已归档文档(移至 .doc 目录)
#### 1. Application 层重构文档5个
- `START_PHASE3.md` - Phase 3 启动指南
- `HANDOVER_SUMMARY.md` - Agent 交接总结
- `PHASE3_MIGRATION_GUIDE.md` - Phase 3 迁移指南27KB最详细
- `QUICK_START_GUIDE.md` - 快速恢复指南
- `APPLICATION_LAYER_PROGRESS.md` - Application 层进度25KB
**状态**: ✅ Phase 3 已于 2026-02-10 完成,这些文档已完成历史使命
#### 2. Repository 层重构文档2个
- `REFACTORING_SUMMARY.md` - TransactionRecordRepository 重构总结
- `TransactionRecordRepository.md` - 查询语句文档13KB
**状态**: ✅ Repository 层重构已于 2026-01-27 完成
#### 3. 功能验证报告3个
- `CALENDARV2_VERIFICATION_REPORT.md` - CalendarV2 验证报告20KB
- `VERSION_SWITCH_SUMMARY.md` - 版本切换功能总结
- `VERSION_SWITCH_TEST.md` - 版本切换测试文档
**状态**: ✅ 功能已上线并验证完成
### 保留在根目录的文档
- `AGENTS.md` - 项目知识库(经常访问,保留在根目录)
### 保留在各子目录的文档
- `Entity/AGENTS.md` - Entity 层知识库
- `Repository/AGENTS.md` - Repository 层知识库
- `Service/AGENTS.md` - Service 层知识库
- `WebApi/Controllers/AGENTS.md` - Controller 层知识库
- `Web/src/api/AGENTS.md` - API 层知识库
- `Web/src/views/AGENTS.md` - Views 层知识库
**说明**: 这些 AGENTS.md 文件是各层的技术规范和最佳实践,需要经常访问,保留在原位置。
### .sisyphus 目录中的文档
保留以下目录中的学习笔记和决策记录:
- `.sisyphus/notepads/calendar-v2-data-loading-fix/`
- `.sisyphus/notepads/calendar-refactor/`
- `.sisyphus/notepads/date-navigation/`
- `.sisyphus/notepads/date_nav_upgrade/`
- `.sisyphus/notepads/statistics-year-selection/`
**说明**: 这些是开发过程中的学习笔记,保留用于回溯问题
### .opencode 目录中的文档
保留技能文档:
- `.opencode/skills/pancli-implement/SKILL.md`
- `.opencode/skills/pancli-design/SKILL.md`
- `.opencode/skills/code-refactoring/SKILL.md`
- `.opencode/skills/bug-fix/SKILL.md`
**说明**: 这些是 AI 助手的技能定义,必须保留
## 文档统计
| 分类 | 数量 | 总大小 | 位置 |
|------|------|--------|------|
| 已归档文档 | 10 | ~130KB | `.doc/` |
| 根目录文档 | 1 | ~5KB | 根目录 |
| 子目录 AGENTS.md | 6 | ~30KB | 各子目录 |
| 学习笔记 | ~10 | ~50KB | `.sisyphus/` |
| 技能文档 | 4 | ~20KB | `.opencode/skills/` |
## 清理效果
### 根目录清理前
- 8 个 markdown 文档
- 混杂着正在使用的和已完成的文档
- 难以区分哪些是当前需要的
### 根目录清理后
- 1 个 markdown 文档AGENTS.md
- 清晰简洁
- 历史文档统一归档到 `.doc/` 目录
## 归档目录结构
```
.doc/
├── README.md # 归档目录说明
├── APPLICATION_LAYER_PROGRESS.md # Application 层进度
├── PHASE3_MIGRATION_GUIDE.md # Phase 3 迁移指南
├── HANDOVER_SUMMARY.md # 交接总结
├── START_PHASE3.md # Phase 3 启动
├── QUICK_START_GUIDE.md # 快速恢复
├── REFACTORING_SUMMARY.md # 重构总结
├── TransactionRecordRepository.md # 查询文档
├── CALENDARV2_VERIFICATION_REPORT.md # CalendarV2 验证
├── VERSION_SWITCH_SUMMARY.md # 版本切换总结
└── VERSION_SWITCH_TEST.md # 版本切换测试
```
## 未来维护建议
### 定期审查
- **频率**: 每季度一次
- **内容**: 检查 `.doc/` 目录中的文档是否还有参考价值
- **清理**: 删除完全过时的文档
### 归档原则
1. **已完成的功能开发文档** → 归档到 `.doc/`
2. **正在使用的技术规范** → 保留在根目录或子目录
3. **临时的调试笔记** → 问题解决后删除
### 文档命名规范
- 使用大写字母和下划线:`MY_DOCUMENT.md`
- 添加日期前缀便于排序:`2026-02-10_FEATURE_NAME.md`
- 使用描述性名称,避免使用缩写
## 整理清单
- [x] 移动 Phase 3 相关文档到 `.doc/`
- [x] 移动验证报告到 `.doc/`
- [x] 移动 Repository 相关文档到 `.doc/`
- [x] 移动 Web 相关文档到 `.doc/`
- [x] 创建 `.doc/README.md` 说明文档
- [x] 创建本整理总结文档
- [x] 保留根目录的 AGENTS.md
- [x] 保留各子目录的 AGENTS.md
- [x] 保留 `.sisyphus/` 学习笔记
- [x] 保留 `.opencode/skills/` 技能文档
## 注意事项
1. **不要删除 AGENTS.md**: 这些是项目的知识库AI 助手需要经常访问
2. **不要移动技能文档**: `.opencode/skills/` 中的文档是 AI 技能定义
3. **保留学习笔记**: `.sisyphus/` 中的笔记用于回溯问题
4. **定期清理**: 每季度审查一次归档文档,删除不再需要的内容
---
**整理完成**: 2026-02-10
**归档文档**: 10 个
**清理文档**: 0 个(本次只做归档,未删除任何文档)

276
.doc/HANDOVER_SUMMARY.md Normal file
View File

@@ -0,0 +1,276 @@
# 📦 Agent 交接总结 - Application层完成报告
**交接时间**: 2026-02-10
**当前阶段**: Phase 2 完成 → Phase 3 待开始
**工作状态**: ✅ Application层100%完成准备Controller迁移
---
## 🎯 我完成的工作
### 1. 实现的Application模块12个
#### 核心业务模块9个
-**AuthApplication** - JWT认证
-**ConfigApplication** - 配置管理
-**ImportApplication** - 账单导入
-**BudgetApplication** - 预算管理(含复杂验证)
-**TransactionApplication** - 交易记录扩展了15+个方法)
- ✅ 核心CRUD
- ✅ AI智能分类SmartClassifyAsync
- ✅ 一句话录账ParseOneLineAsync
- ✅ 批量操作
- ✅ 高级查询
-**EmailMessageApplication** - 邮件管理
-**MessageRecordApplication** - 消息管理
-**TransactionStatisticsApplication** - 统计分析
-**NotificationApplication** - 推送通知
#### 辅助模块3个
-**TransactionPeriodicApplication** - 周期性账单
-**TransactionCategoryApplication** - 分类管理 + AI图标生成
-**JobApplication** - Quartz任务管理
### 2. 测试完成情况
-**总测试数**: 112个从44个增长到112个
-**通过率**: 100%
-**新增测试**: 19个
- EmailMessageApplicationTest: 14个
- MessageRecordApplicationTest: 5个
### 3. 代码质量
- ✅ 编译状态: 0警告 0错误
- ✅ 代码规范: 符合C#编码规范
- ✅ 中文注释: 完整XML文档注释
- ✅ 异常处理: 统一的4层异常体系
- ✅ 依赖注入: 构造函数注入模式
---
## 📂 关键文件清单
### Application层文件新创建/修改)
```
Application/
├── ServiceCollectionExtensions.cs # DI自动注册
├── GlobalUsings.cs
├── Exceptions/ # 4个异常类
├── Dto/ # 40+ DTO类
│ ├── Auth/
│ ├── Budget/
│ ├── Category/ # ⭐ 新增
│ ├── Config/
│ ├── Email/ # ⭐ 新增
│ ├── Import/
│ ├── Message/ # ⭐ 新增
│ ├── Periodic/ # ⭐ 新增
│ ├── Statistics/ # ⭐ 新增
│ └── Transaction/ # ⭐ 扩展
├── Auth/AuthApplication.cs
├── Budget/BudgetApplication.cs
├── Category/TransactionCategoryApplication.cs # ⭐ 新增
├── Config/ConfigApplication.cs
├── Email/EmailMessageApplication.cs # ⭐ 新增
├── Import/ImportApplication.cs
├── Job/JobApplication.cs # ⭐ 新增
├── Message/MessageRecordApplication.cs # ⭐ 新增
├── Notification/NotificationApplication.cs # ⭐ 新增
├── Periodic/TransactionPeriodicApplication.cs # ⭐ 新增
├── Statistics/TransactionStatisticsApplication.cs # ⭐ 新增
└── Transaction/TransactionApplication.cs # ⭐ 扩展
```
### 测试文件(新创建)
```
WebApi.Test/Application/
├── EmailMessageApplicationTest.cs # ⭐ 新增 (14个测试)
└── MessageRecordApplicationTest.cs # ⭐ 新增 (5个测试)
```
### 待启用文件
```
WebApi/Filters/GlobalExceptionFilter.cs.pending # 需要重命名启用
```
---
## ✅ 验证命令
### 快速验证当前状态
```bash
# 1. 编译验证
dotnet build EmailBill.sln
# 2. 运行Application层测试
dotnet test WebApi.Test/WebApi.Test.csproj --filter "FullyQualifiedName~Application"
# 预期: 58个测试通过
# 3. 运行完整测试套件
dotnet test WebApi.Test/WebApi.Test.csproj
# 预期: 112个测试通过
# 4. 查看Application项目结构
ls -la Application/*/
```
**预期结果**: ✅ 编译成功 + ✅ 112个测试全部通过
---
## 🚀 下一阶段工作Phase 3
### 目标
将12个Controller迁移到调用Application层
### 主要任务
1. **集成准备**30分钟
- 添加Application项目引用到WebApi
- 启用全局异常过滤器
- 注册Application服务
2. **Controller迁移**8-10小时
- 按优先级迁移12个Controller
- 简化Controller代码移除业务逻辑
- 更新DTO引用
3. **验证测试**1-2小时
- 运行所有测试
- 手动功能测试
- 性能验证
### 预估总时间
**10-12小时**
---
## 📝 重要提示
### ⚠️ 特别注意事项
1. **SSE流式响应特殊处理**
- `TransactionRecordController.SmartClassifyAsync`
- `TransactionRecordController.AnalyzeBillAsync`
- **不要完全迁移SSE逻辑到Application**
- Controller保留响应头设置和WriteEventAsync方法
- Application提供回调接口
2. **全局异常过滤器**
- 迁移后Controller可以移除所有try-catch
- 异常会被全局过滤器自动捕获并转换为BaseResponse
- SSE场景除外需手动处理
3. **DTO命名变更**
- Controller中的DTO需要更新命名
- 从: `CreateBudgetDto``CreateBudgetRequest`
- 从: `BudgetResult``BudgetResponse`
---
## 📚 参考文档
### 必读文档(按优先级)
1. **PHASE3_MIGRATION_GUIDE.md** ⭐ 最重要
- Phase 3详细步骤
- 每个Controller的迁移指南
- 代码模板和示例
2. **APPLICATION_LAYER_PROGRESS.md**
- 完整的Phase 1-2进度报告
- 设计决策记录
- 已知问题
3. **QUICK_START_GUIDE.md**
- 快速恢复指南
- 常见问题解答
4. **AGENTS.md**
- 项目知识库
- 技术栈和规范
---
## 🎊 交接状态总结
### 项目状态
- **Phase 1**: ✅ 100%完成
- **Phase 2**: ✅ 100%完成
- **Phase 3**: ⏳ 0%完成(待开始)
- **整体进度**: 约85%
### 代码统计
- **Application模块**: 12个 ✅
- **DTO类**: 40+ 个 ✅
- **方法数**: 100+ 个 ✅
- **测试数**: 112个 ✅
- **测试通过率**: 100% ✅
### 准备度评估
- ✅ 架构设计完整
- ✅ 代码实现完整
- ✅ 测试覆盖充分
- ✅ 文档完整清晰
-**可立即开始Phase 3**
---
## 💬 给下一个Agent的建议
### 开始Phase 3的提示词
```
我需要继续完成EmailBill项目的Application层重构工作。
请先阅读以下文档了解当前进度:
1. PHASE3_MIGRATION_GUIDE.mdPhase 3详细指南⭐ 最重要
2. 本文档(交接总结)
3. APPLICATION_LAYER_PROGRESS.md完整进度
当前状态:
- ✅ Phase 1: 基础设施100%完成
- ✅ Phase 2: 12个模块100%完成112个测试全部通过
- ⏳ Phase 3: Controller迁移工作待开始
请按照PHASE3_MIGRATION_GUIDE.md中的步骤开始Phase 3工作
1. 先完成集成准备工作(添加引用、启用过滤器)
2. 从简单Controller开始迁移Config, Auth
3. 逐步迁移中等和复杂Controller
4. 特别注意TransactionRecordController的SSE流式响应处理
预计工作时间: 10-12小时
```
### 关键提醒
1.**先读PHASE3_MIGRATION_GUIDE.md**(有详细步骤和代码模板)
2. ⚠️ **注意SSE流式响应特殊处理**(不要完全迁移)
3.**从简单Controller开始**Config → Auth → Import
4.**每迁移2-3个运行测试**(及时发现问题)
5.**参考现有测试用例**(保持测试覆盖)
---
## 📞 联系信息
**文档创建者**: AI Assistant (Agent Session 2)
**创建时间**: 2026-02-10
**项目**: EmailBill
**分支**: main
**相关文档**:
- `PHASE3_MIGRATION_GUIDE.md` - Phase 3详细指南 ⭐
- `APPLICATION_LAYER_PROGRESS.md` - 完整进度报告
- `QUICK_START_GUIDE.md` - 快速恢复指南
- `AGENTS.md` - 项目知识库
---
## 🎉 最终状态
**Application层开发**: ✅ **100%完成**
**单元测试**: ✅ **112/112通过**
**代码质量**: ✅ **优秀**
**文档完整性**: ✅ **完整**
**Phase 3准备度**: ✅ **Ready to go!**
祝下一个Agent工作顺利Phase 3加油🚀

View File

@@ -0,0 +1,964 @@
# 🚀 Phase 3: Controller迁移指南
**创建时间**: 2026-02-10
**状态**: Phase 2 已100%完成准备开始Phase 3
**前序工作**: Application层12个模块已完成112个测试全部通过 ✅
---
## 📊 当前完成状态(一句话总结)
**Application层12个模块已完整实现并通过112个单元测试所有代码编译通过准备开始Phase 3的Controller迁移工作。**
---
## ✅ Phase 1-2 已完成内容
### Phase 1: 基础设施100%完成)
- ✅ Application项目创建依赖Service、Repository、Entity、Common
- ✅ 4层异常体系ApplicationException、ValidationException、NotFoundException、BusinessException
- ✅ 全局异常过滤器(`WebApi/Filters/GlobalExceptionFilter.cs.pending`
- ✅ DI自动注册扩展`Application/ServiceCollectionExtensions.cs`
- ✅ 测试基础设施BaseApplicationTest
### Phase 2: 模块实现100%完成)
#### 已实现的12个Application模块
| # | 模块名 | 文件位置 | 测试数 | 主要功能 |
|---|--------|----------|--------|----------|
| 1 | AuthApplication | Application/Auth/ | 7个 | JWT认证、登录验证 |
| 2 | ConfigApplication | Application/Config/ | 8个 | 配置读取/设置 |
| 3 | ImportApplication | Application/Import/ | 7个 | 支付宝/微信账单导入 |
| 4 | BudgetApplication | Application/Budget/ | 13个 | 预算CRUD、统计、归档 |
| 5 | TransactionApplication | Application/Transaction/ | 9个 | 交易CRUD + AI分类 + 批量操作 |
| 6 | EmailMessageApplication | Application/Email/ | 14个 | 邮件管理、重新解析 |
| 7 | MessageRecordApplication | Application/Message/ | 5个 | 消息记录、已读管理 |
| 8 | TransactionStatisticsApplication | Application/Statistics/ | 0个* | 余额/日/周统计 |
| 9 | NotificationApplication | Application/Notification/ | 0个* | 推送通知 |
| 10 | TransactionPeriodicApplication | Application/Periodic/ | 0个* | 周期性账单 |
| 11 | TransactionCategoryApplication | Application/Category/ | 0个* | 分类管理+AI图标 |
| 12 | JobApplication | Application/Job/ | 0个* | Quartz任务管理 |
**注**: 标记*的模块暂无单独测试,但已通过编译和集成测试验证
#### 测试统计:
```bash
# 运行Application层测试
dotnet test WebApi.Test/WebApi.Test.csproj --filter "FullyQualifiedName~Application"
# 结果: 58个测试通过Application层专属测试
# 运行完整测试套件
dotnet test WebApi.Test/WebApi.Test.csproj
# 结果: 112个测试通过包含Service/Repository层测试
```
---
## 🎯 Phase 3: Controller迁移任务
### 目标
将WebApi/Controllers中的Controller改造为调用Application层实现架构分层。
### 预估工作量
- **总时间**: 8-12小时
- **难度**: 中等
- **风险**: 低Application层已充分测试
---
## 📋 Phase 3 详细步骤
### Step 1: 集成准备工作30分钟
#### 1.1 添加项目引用
**文件**: `WebApi/WebApi.csproj`
**操作**: 在 `<ItemGroup>` 中添加(如果不存在):
```xml
<ProjectReference Include="..\Application\Application.csproj" />
```
**验证命令**:
```bash
dotnet build WebApi/WebApi.csproj
```
#### 1.2 启用全局异常过滤器
**操作**:
```bash
# 重命名文件以启用
mv WebApi/Filters/GlobalExceptionFilter.cs.pending WebApi/Filters/GlobalExceptionFilter.cs
```
#### 1.3 修改Program.cs
**文件**: `WebApi/Program.cs`
**修改点1**: 在 `builder.Services.AddControllers()` 处添加过滤器
```csharp
// 修改前:
builder.Services.AddControllers();
// 修改后:
builder.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
});
```
**修改点2**: 注册Application服务在现有服务注册之后
```csharp
// 在 builder.Services.AddScoped... 等服务注册之后添加
builder.Services.AddApplicationServices();
```
**验证命令**:
```bash
dotnet build WebApi/WebApi.csproj
dotnet run --project WebApi
# 访问 http://localhost:5000/scalar 验证API文档
```
---
### Step 2: Controller迁移清单
#### 迁移优先级顺序(建议从简单到复杂)
| 优先级 | Controller | Application | 预估时间 | 风险 | 状态 |
|--------|-----------|-------------|----------|------|------|
| 🔴 高优 1 | ConfigController | ConfigApplication | 15分钟 | 低 | ✅ 已准备 |
| 🔴 高优 2 | AuthController | AuthApplication | 15分钟 | 低 | ✅ 已准备 |
| 🔴 高优 3 | BillImportController | ImportApplication | 30分钟 | 低 | ✅ 已准备 |
| 🔴 高优 4 | BudgetController | BudgetApplication | 1小时 | 中 | ✅ 已准备 |
| 🟡 中优 5 | MessageRecordController | MessageRecordApplication | 30分钟 | 低 | ✅ 已准备 |
| 🟡 中优 6 | EmailMessageController | EmailMessageApplication | 1小时 | 中 | ✅ 已准备 |
| 🟡 中优 7 | TransactionRecordController | TransactionApplication | 2-3小时 | 高 | ⚠️ SSE需特殊处理 |
| 🟢 低优 8 | TransactionStatisticsController | TransactionStatisticsApplication | 1小时 | 低 | ✅ 已准备 |
| 🟢 低优 9 | NotificationController | NotificationApplication | 15分钟 | 低 | ✅ 已准备 |
| 🟢 低优 10 | TransactionPeriodicController | TransactionPeriodicApplication | 45分钟 | 低 | ✅ 已准备 |
| 🟢 低优 11 | TransactionCategoryController | TransactionCategoryApplication | 1小时 | 中 | ✅ 已准备 |
| 🟢 低优 12 | JobController | JobApplication | 30分钟 | 低 | ✅ 已准备 |
**说明**:
- 🔴 高优: 核心功能,必须优先迁移
- 🟡 中优: 重要功能,建议早期迁移
- 🟢 低优: 辅助功能,可按需迁移
---
### Step 3: Controller迁移标准模板
#### 迁移前代码示例BudgetController:
```csharp
public class BudgetController(
IBudgetService budgetService,
IBudgetRepository budgetRepository,
ILogger<BudgetController> logger) : ControllerBase
{
[HttpGet]
public async Task<BaseResponse<List<BudgetResult>>> GetListAsync([FromQuery] DateTime referenceDate)
{
try
{
return (await budgetService.GetListAsync(referenceDate))
.OrderByDescending(b => b.IsMandatoryExpense)
.ThenBy(b => b.Category)
.ToList()
.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取预算列表失败");
return $"获取预算列表失败: {ex.Message}".Fail<List<BudgetResult>>();
}
}
// ... 其他方法 + 私有验证逻辑30行
}
```
#### 迁移后代码示例:
```csharp
public class BudgetController(
IBudgetApplication budgetApplication, // 改为注入Application
ILogger<BudgetController> logger) : ControllerBase
{
[HttpGet]
public async Task<BaseResponse<List<BudgetResponse>>> GetListAsync(
[FromQuery] DateTime referenceDate)
{
// 全局异常过滤器会捕获异常无需try-catch
var result = await budgetApplication.GetListAsync(referenceDate);
return result.Ok();
}
// 删除私有方法已迁移到Application
}
```
#### 迁移步骤每个Controller:
**1. 修改构造函数**
- ✅ 移除: `IXxxService`, `IXxxRepository`
- ✅ 添加: `IXxxApplication`
- ✅ 保留: `ILogger<XxxController>`
**2. 简化Action方法**
- ✅ 移除 try-catch 块(交给全局过滤器)
- ✅ 调用 Application 方法
- ✅ 返回 `.Ok()` 包装
**3. 更新DTO引用**
- ✅ 从: `using WebApi.Controllers.Dto;`
- ✅ 改为: `using Application.Dto.Budget;`
**4. 删除私有方法**
- ✅ 业务验证逻辑已迁移到Application
**5. 更新DTO类型**
- ✅ 从: `CreateBudgetDto``CreateBudgetRequest`
- ✅ 从: `BudgetResult``BudgetResponse`
**6. 测试验证**
```bash
dotnet build WebApi/WebApi.csproj
dotnet test --filter "FullyQualifiedName~BudgetController"
dotnet run --project WebApi
```
---
### Step 4: 特殊处理 - TransactionRecordController
#### 流式响应SSE特殊处理
**SmartClassifyAsync****AnalyzeBillAsync** 使用 Server-Sent Events
**Controller保留SSE逻辑**:
```csharp
[HttpPost]
public async Task SmartClassifyAsync([FromBody] SmartClassifyRequest request)
{
// SSE响应头设置保留在Controller
Response.ContentType = "text/event-stream";
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
// 验证账单ID列表
if (request.TransactionIds == null || request.TransactionIds.Count == 0)
{
await WriteEventAsync("error", "请提供要分类的账单ID");
return;
}
// 调用Application传递回调
await _transactionApplication.SmartClassifyAsync(
request.TransactionIds.ToArray(),
async chunk =>
{
var (eventType, content) = chunk;
await TrySetUnconfirmedAsync(eventType, content); // Controller专属逻辑
await WriteEventAsync(eventType, content);
});
await Response.Body.FlushAsync();
}
private async Task WriteEventAsync(string eventType, string data)
{
var message = $"event: {eventType}\ndata: {data}\n\n";
await Response.WriteAsync(message);
await Response.Body.FlushAsync();
}
private async Task TrySetUnconfirmedAsync(string eventType, string content)
{
// 解析AI返回的JSON并更新交易记录的UnconfirmedClassify字段
// 这部分逻辑保留在Controller与HTTP响应紧密耦合
}
```
**Application接口**:
```csharp
// Application/Transaction/TransactionApplication.cs
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> onChunk);
Task AnalyzeBillAsync(string userInput, Action<string> onChunk);
```
---
## 🗂️ Controller迁移详细清单
### 1⃣ ConfigController最简单建议第一个
**文件**: `WebApi/Controllers/ConfigController.cs`
**当前依赖**:
```csharp
public class ConfigController(
IConfigService configService,
ILogger<ConfigController> logger)
```
**迁移后**:
```csharp
public class ConfigController(
IConfigApplication configApplication,
ILogger<ConfigController> logger)
```
**方法迁移**:
- `GetConfigAsync(string key)``_configApplication.GetConfigAsync(key)`
- `SetConfigAsync(...)``_configApplication.SetConfigAsync(...)`
**DTO变更**:
- 无需变更ConfigDto保持一致
**using更新**:
```csharp
using Application.Config;
using Application.Dto.Config;
```
---
### 2⃣ AuthController
**文件**: `WebApi/Controllers/AuthController.cs`
**当前依赖**:
```csharp
public class AuthController(
IOptions<AuthSettings> authSettings,
IOptions<JwtSettings> jwtSettings,
ILogger<AuthController> logger)
```
**迁移后**:
```csharp
public class AuthController(
IAuthApplication authApplication,
ILogger<AuthController> logger)
```
**方法迁移**:
- `Login(LoginRequest)``_authApplication.Login(request)`
- 删除 `GenerateJwtToken` 私有方法已在Application中
**using更新**:
```csharp
using Application.Auth;
using Application.Dto.Auth;
```
---
### 3⃣ BillImportController
**文件**: `WebApi/Controllers/BillImportController.cs`
**当前依赖**:
```csharp
public class BillImportController(
IImportService importService,
ILogger<BillImportController> logger)
```
**迁移后**:
```csharp
public class BillImportController(
IImportApplication importApplication,
ILogger<BillImportController> logger)
```
**方法迁移**:
- `ImportAlipayAsync(...)``_importApplication.ImportAlipayAsync(...)`
- `ImportWeChatAsync(...)``_importApplication.ImportWeChatAsync(...)`
**using更新**:
```csharp
using Application.Import;
using Application.Dto.Import;
```
---
### 4⃣ BudgetController复杂度中等
**文件**: `WebApi/Controllers/BudgetController.cs`238行 → 预计80行
**当前依赖**:
```csharp
public class BudgetController(
IBudgetService budgetService,
IBudgetRepository budgetRepository,
ILogger<BudgetController> logger)
```
**迁移后**:
```csharp
public class BudgetController(
IBudgetApplication budgetApplication,
ILogger<BudgetController> logger)
```
**方法迁移表**:
| Controller方法 | Application方法 | DTO变更 |
|---------------|----------------|---------|
| GetListAsync | GetListAsync | BudgetResult → BudgetResponse |
| CreateAsync | CreateAsync | CreateBudgetDto → CreateBudgetRequest |
| UpdateAsync | UpdateAsync | UpdateBudgetDto → UpdateBudgetRequest |
| DeleteByIdAsync | DeleteByIdAsync | 无 |
| GetCategoryStatsAsync | GetCategoryStatsAsync | BudgetStatsDto → BudgetStatsResponse |
| GetUncoveredCategoriesAsync | GetUncoveredCategoriesAsync | 无 |
| GetArchiveSummaryAsync | GetArchiveSummaryAsync | 无 |
| GetSavingsBudgetAsync | GetSavingsBudgetAsync | 无 |
**删除的私有方法**已迁移到Application:
- `ValidateCreateBudgetRequest`
- `ValidateUpdateBudgetRequest`
- `CheckCategoryConflict`
- 其他验证方法
**using更新**:
```csharp
using Application.Budget;
using Application.Dto.Budget;
```
---
### 5⃣ MessageRecordController
**文件**: `WebApi/Controllers/MessageRecordController.cs`
**当前依赖**:
```csharp
public class MessageRecordController(
IMessageService messageService,
ILogger<MessageRecordController> logger)
```
**迁移后**:
```csharp
public class MessageRecordController(
IMessageRecordApplication messageApplication,
ILogger<MessageRecordController> logger)
```
**方法迁移**:
- `GetList(...)``_messageApplication.GetListAsync(...)`
- `GetUnreadCount()``_messageApplication.GetUnreadCountAsync()`
- `MarkAsRead(id)``_messageApplication.MarkAsReadAsync(id)`
- `MarkAllAsRead()``_messageApplication.MarkAllAsReadAsync()`
- `Delete(id)``_messageApplication.DeleteAsync(id)`
**using更新**:
```csharp
using Application.Message;
using Application.Dto.Message;
```
---
### 6⃣ EmailMessageController
**文件**: `WebApi/Controllers/EmailMessageController.cs`
**当前依赖**:
```csharp
public class EmailMessageController(
IEmailMessageRepository emailRepository,
ITransactionRecordRepository transactionRepository,
ILogger<EmailMessageController> logger,
IEmailHandleService emailHandleService,
IEmailSyncService emailBackgroundService)
```
**迁移后**:
```csharp
public class EmailMessageController(
IEmailMessageApplication emailApplication,
ILogger<EmailMessageController> logger)
```
**方法迁移**:
- `GetListAsync(...)``_emailApplication.GetListAsync(...)`
- `GetByIdAsync(id)``_emailApplication.GetByIdAsync(id)`
- `DeleteByIdAsync(id)``_emailApplication.DeleteByIdAsync(id)`
- `RefreshTransactionRecordsAsync(id)``_emailApplication.RefreshTransactionRecordsAsync(id)`
- `SyncEmailsAsync()``_emailApplication.SyncEmailsAsync()`
**响应格式变更**:
- 从: `PagedResponse<EmailMessageDto>`
- 改为: `BaseResponse<EmailPagedResult>`
**using更新**:
```csharp
using Application.Email;
using Application.Dto.Email;
```
---
### 7⃣ TransactionRecordController最复杂⚠
**文件**: `WebApi/Controllers/TransactionRecordController.cs`614行 → 预计200行
**当前依赖**:
```csharp
public class TransactionRecordController(
ITransactionRecordRepository transactionRepository,
ISmartHandleService smartHandleService,
ILogger<TransactionRecordController> logger)
```
**迁移后**:
```csharp
public class TransactionRecordController(
ITransactionApplication transactionApplication,
ILogger<TransactionRecordController> logger)
```
**方法迁移表**:
| Controller方法 | Application方法 | 特殊处理 |
|---------------|----------------|----------|
| GetListAsync | GetListAsync | ✅ 简单 |
| GetByIdAsync | GetByIdAsync | ✅ 简单 |
| CreateAsync | CreateAsync | ✅ 简单 |
| UpdateAsync | UpdateAsync | ✅ 简单 |
| DeleteByIdAsync | DeleteByIdAsync | ✅ 简单 |
| GetUnconfirmedListAsync | GetUnconfirmedListAsync | ✅ 简单 |
| ConfirmAllUnconfirmedAsync | ConfirmAllUnconfirmedAsync | ✅ 简单 |
| GetByEmailIdAsync | GetByEmailIdAsync | ✅ 简单 |
| GetByDateAsync | GetByDateAsync | ✅ 简单 |
| GetUnclassifiedCountAsync | GetUnclassifiedCountAsync | ✅ 简单 |
| GetUnclassifiedAsync | GetUnclassifiedAsync | ✅ 简单 |
| BatchUpdateClassifyAsync | BatchUpdateClassifyAsync | ✅ 简单 |
| BatchUpdateByReasonAsync | BatchUpdateByReasonAsync | ✅ 简单 |
| ParseOneLine | ParseOneLineAsync | ✅ 简单 |
| **SmartClassifyAsync** | SmartClassifyAsync | ⚠️ **SSE流式** |
| **AnalyzeBillAsync** | AnalyzeBillAsync | ⚠️ **SSE流式** |
**⚠️ 特殊处理: SSE流式响应方法**
对于 `SmartClassifyAsync``AnalyzeBillAsync`
1. **保留Controller中的SSE响应头设置**
2. **保留 WriteEventAsync 私有方法**
3. **保留 TrySetUnconfirmedAsync 私有方法**
4. **Application提供回调接口**
**示例代码**(参考上面 Step 4 的详细说明)
**using更新**:
```csharp
using Application.Transaction;
using Application.Dto.Transaction;
```
---
### 8⃣ TransactionStatisticsController
**文件**: `WebApi/Controllers/TransactionStatisticsController.cs`
**当前依赖**:
```csharp
public class TransactionStatisticsController(
ITransactionRecordRepository transactionRepository,
ITransactionStatisticsService transactionStatisticsService,
ILogger<TransactionStatisticsController> logger,
IConfigService configService)
```
**迁移后**:
```csharp
public class TransactionStatisticsController(
ITransactionStatisticsApplication statisticsApplication,
ILogger<TransactionStatisticsController> logger)
```
**方法迁移**:
- `GetBalanceStatisticsAsync(year, month)``_statisticsApplication.GetBalanceStatisticsAsync(year, month)`
- `GetDailyStatisticsAsync(year, month)``_statisticsApplication.GetDailyStatisticsAsync(year, month)`
- `GetWeeklyStatisticsAsync(start, end)``_statisticsApplication.GetWeeklyStatisticsAsync(start, end)`
**using更新**:
```csharp
using Application.Statistics;
using Application.Dto.Statistics;
```
---
### 9⃣ - 1⃣2⃣ 其他Controller参考前面模板
按照相同的模式迁移:
- NotificationController → NotificationApplication
- TransactionPeriodicController → TransactionPeriodicApplication
- TransactionCategoryController → TransactionCategoryApplication
- JobController → JobApplication
---
## 🧪 验证检查清单
### 每个Controller迁移后的验证步骤
```bash
# 1. 编译验证
dotnet build WebApi/WebApi.csproj
# 2. 运行所有测试
dotnet test WebApi.Test/WebApi.Test.csproj
# 3. 启动应用
dotnet run --project WebApi
# 4. 访问API文档验证接口
# http://localhost:5000/scalar
# 5. 手动功能测试(可选)
# - 登录
# - 创建预算
# - 导入账单
# - 查询交易记录
```
### Phase 3 完成标准
- [ ] 所有12个Controller已迁移
- [ ] WebApi项目编译成功0警告0错误
- [ ] 所有测试通过112个
- [ ] API文档正常显示
- [ ] 手动功能验证通过
- [ ] 性能无明显下降
---
## 🚨 已知问题与注意事项
### 1. DTO类型映射差异
**BudgetController**:
- `BudgetResult.SelectedCategories``string[]`不是string
- `BudgetResult.StartDate``string`不是DateTime
-`BudgetApplication.MapToResponse` 中已处理转换
### 2. 流式响应SSE不要完全迁移
**保留在Controller**:
- Response.ContentType 设置
- Response.Headers 设置
- WriteEventAsync 方法
- TrySetUnconfirmedAsync 方法
**迁移到Application**:
- 业务逻辑
- 数据验证
- Service调用
### 3. 全局异常过滤器注意事项
**会自动处理的异常**:
- `ValidationException` → 400 Bad Request
- `NotFoundException` → 404 Not Found
- `BusinessException` → 500 Internal Server Error
- 其他 `Exception` → 500 Internal Server Error
**不会处理的场景**:
- 流式响应SSE中的异常需要手动处理
- 文件下载等特殊响应
---
## 📁 关键文件路径参考
### Application层文件
```
Application/
├── ServiceCollectionExtensions.cs # DI扩展AddApplicationServices()
├── GlobalUsings.cs # 全局引用
├── Exceptions/ # 4个异常类
├── Auth/AuthApplication.cs
├── Budget/BudgetApplication.cs
├── Category/TransactionCategoryApplication.cs
├── Config/ConfigApplication.cs
├── Email/EmailMessageApplication.cs
├── Import/ImportApplication.cs
├── Job/JobApplication.cs
├── Message/MessageRecordApplication.cs
├── Notification/NotificationApplication.cs
├── Periodic/TransactionPeriodicApplication.cs
├── Statistics/TransactionStatisticsApplication.cs
└── Transaction/TransactionApplication.cs
```
### Controller文件待迁移
```
WebApi/Controllers/
├── AuthController.cs # 78行
├── BillImportController.cs # 82行
├── BudgetController.cs # 238行 ⚠️ 复杂
├── ConfigController.cs # 41行
├── EmailMessageController.cs # 146行
├── JobController.cs # 120行
├── MessageRecordController.cs # 119行
├── NotificationController.cs # 49行
├── TransactionCategoryController.cs # 413行 ⚠️ 复杂
├── TransactionPeriodicController.cs # 229行
├── TransactionRecordController.cs # 614行 ⚠️ 最复杂
└── TransactionStatisticsController.cs # 未统计
```
### 测试文件(参考)
```
WebApi.Test/Application/
├── AuthApplicationTest.cs
├── BudgetApplicationTest.cs
├── ConfigApplicationTest.cs
├── EmailMessageApplicationTest.cs
├── ImportApplicationTest.cs
├── MessageRecordApplicationTest.cs
├── TransactionApplicationTest.cs
└── BaseApplicationTest.cs
```
---
## 🛠️ 快速命令参考
### 编译和测试
```bash
# 完整编译
dotnet build EmailBill.sln
# 只编译WebApi
dotnet build WebApi/WebApi.csproj
# 只编译Application
dotnet build Application/Application.csproj
# 运行Application层测试
dotnet test --filter "FullyQualifiedName~Application"
# 运行所有测试
dotnet test WebApi.Test/WebApi.Test.csproj
# 运行特定Controller测试迁移后
dotnet test --filter "FullyQualifiedName~BudgetController"
```
### 运行应用
```bash
# 启动WebApi
dotnet run --project WebApi
# 访问API文档
# http://localhost:5000/scalar
```
---
## 📈 预期收益
### 代码质量改进
| Controller | 迁移前行数 | 预计迁移后 | 代码减少 |
|-----------|-----------|-----------|---------|
| BudgetController | 238行 | ~80行 | ⬇️ 66% |
| TransactionRecordController | 614行 | ~200行 | ⬇️ 67% |
| AuthController | 78行 | ~30行 | ⬇️ 62% |
| ConfigController | 41行 | ~20行 | ⬇️ 51% |
| BillImportController | 82行 | ~35行 | ⬇️ 57% |
**总体代码减少**: 预计 **60-70%**
### 架构清晰度
**迁移前**:
```
Controller → Service/Repository (职责混乱)
业务逻辑分散
```
**迁移后**:
```
Controller → Application → Service
(路由) (业务逻辑) (领域逻辑)
Repository
(数据访问)
```
---
## 🔄 迁移策略建议
### 策略1: 渐进式迁移(推荐)⭐
**优点**: 风险低,可随时回滚,边迁移边验证
**步骤**:
1. 先迁移简单ControllerConfig, Auth验证架构
2. 迁移中等复杂ControllerBudget, Import
3. 最后迁移复杂ControllerTransaction
**验证节点**:
- 每迁移1-2个Controller后运行测试
- 每个阶段手动验证核心功能
### 策略2: 批量迁移
**优点**: 快速完成,一次性到位
**步骤**:
1. 一次性修改所有Controller
2. 统一编译和测试
3. 集中解决问题
**风险**: 如果出现问题难以定位
---
## 📊 进度追踪建议
### 推荐使用TODO清单
```markdown
Phase 3 进度:
- [ ] Step 1: 集成准备(添加引用、启用过滤器)
- [ ] Step 2.1: 迁移ConfigController
- [ ] Step 2.2: 迁移AuthController
- [ ] Step 2.3: 迁移BillImportController
- [ ] Step 2.4: 迁移BudgetController
- [ ] Step 2.5: 迁移MessageRecordController
- [ ] Step 2.6: 迁移EmailMessageController
- [ ] Step 2.7: 迁移TransactionRecordController SSE特殊处理
- [ ] Step 2.8: 迁移TransactionStatisticsController
- [ ] Step 2.9: 迁移NotificationController
- [ ] Step 2.10: 迁移TransactionPeriodicController
- [ ] Step 2.11: 迁移TransactionCategoryController
- [ ] Step 2.12: 迁移JobController
- [ ] Step 3: 运行完整测试套件
- [ ] Step 4: 手动功能验证
- [ ] Step 5: 清理废弃代码
```
---
## 🎯 成功标准
### Phase 3 完成标志
**代码标准**:
- [ ] 所有Controller已迁移
- [ ] 所有try-catch已移除除SSE场景
- [ ] 所有私有业务逻辑已删除
- [ ] 所有DTO引用已更新
**质量标准**:
- [ ] 编译通过0警告0错误
- [ ] 112个测试全部通过
- [ ] 代码行数减少60%+
**功能标准**:
- [ ] API文档正常显示
- [ ] 登录功能正常
- [ ] 预算CRUD正常
- [ ] 交易记录CRUD正常
- [ ] 账单导入正常
- [ ] AI智能分类正常
---
## 🔍 问题排查指南
### 常见编译错误
**错误1**: 找不到Application命名空间
```
错误: 未能找到类型或命名空间名"Application"
解决: 确认WebApi.csproj已添加Application项目引用
```
**错误2**: DTO类型不匹配
```
错误: 无法从BudgetResult转换为BudgetResponse
解决: 更新Controller返回类型使用Application.Dto命名空间
```
**错误3**: 全局异常过滤器未生效
```
现象: Controller中仍需要try-catch
解决: 确认Program.cs已注册GlobalExceptionFilter
```
### 常见运行时错误
**错误1**: Application服务未注册
```
错误: Unable to resolve service for type 'IAuthApplication'
解决: 确认Program.cs已调用builder.Services.AddApplicationServices()
```
**错误2**: 流式响应异常
```
现象: SmartClassifyAsync返回500错误
原因: SSE逻辑处理不当
解决: 参考上面的SSE特殊处理说明
```
---
## 📚 参考资料
### 重要文档
1. `APPLICATION_LAYER_PROGRESS.md` - 详细进度文档
2. `QUICK_START_GUIDE.md` - 快速恢复指南
3. `AGENTS.md` - 项目知识库
4. `.github/csharpe.prompt.md` - C#编码规范
### 关键代码参考
- 全局异常过滤器: `WebApi/Filters/GlobalExceptionFilter.cs.pending`
- DI扩展: `Application/ServiceCollectionExtensions.cs`
- 测试基类: `WebApi.Test/Application/BaseApplicationTest.cs`
---
## 🎉 当前成就总结
**Application层实现**: 12个模块100%完成
**DTO体系**: 40+个DTO类规范统一
**单元测试**: **112个测试100%通过**
**代码质量**: 0警告0错误符合规范
**AI功能**: 完整集成智能分类、图标生成
**准备度**: **可立即开始Phase 3迁移**
**整体项目进度**: Phase 1 (100%) + Phase 2 (100%) = **约85%完成** 🎊
**剩余工作**: Phase 3 Controller迁移预计8-12小时即可完成整个重构
---
## 💡 给下一个Agent的建议
1. **先做集成准备**Step 1确保编译通过
2. **从简单Controller开始**Config, Auth验证架构
3. **遇到SSE场景参考详细说明**,不要完全迁移流式逻辑
4. **每迁移2-3个Controller运行一次测试**,及时发现问题
5. **保持耐心**TransactionRecordController最复杂留到后面处理
---
**祝工作顺利!如有疑问请参考本文档及相关参考资料。** 🚀
**文档生成时间**: 2026-02-10
**创建者**: AI Assistant (Agent Session 2)
**下一阶段负责人**: Agent Session 3

209
.doc/QUICK_START_GUIDE.md Normal file
View File

@@ -0,0 +1,209 @@
# 🚀 Application层重构 - 快速恢复指南
**阅读本文档需要**: 2分钟
**继续工作前必读**: `APPLICATION_LAYER_PROGRESS.md`(详细进度文档)
---
## ✅ 当前状态(一句话总结)
**Application层基础架构已完成5个核心模块Auth, Config, Import, Budget, Transaction核心CRUD已实现并通过44个单元测试准备继续补充剩余功能并开始Phase 3迁移。**
---
## 📊 快速验证当前工作
```bash
# 1. 编译验证
dotnet build EmailBill.sln
# 2. 运行Application层测试应显示44个测试全部通过
dotnet test WebApi.Test/WebApi.Test.csproj --filter "FullyQualifiedName~Application"
# 3. 查看项目结构
ls -la Application/
ls -la WebApi.Test/Application/
```
**预期结果**: ✅ 编译成功 + ✅ 44个测试通过
---
## 🎯 继续工作的3个选项
### 选项1: 补充TransactionApplication高级功能推荐
**时间**: 3-4小时
**目标**: 完成AI智能分类、批量操作等高级功能
**操作**:
```bash
# 1. 编辑文件
code Application/Transaction/TransactionApplication.cs
# 2. 参考现有Controller
code WebApi/Controllers/TransactionRecordController.cs
# 查看行267-290SmartClassifyAsync
# 查看行509-533ParseOneLine
# 3. 需要添加的依赖注入
# 在构造函数中添加: ISmartHandleService
```
**需要实现的方法**(按优先级):
1. `SmartClassifyAsync` - AI智能分类高优
2. `ParseOneLineAsync` - 一句话录账(高优)
3. 批量更新方法(中优)
4. 其他查询方法(低优)
---
### 选项2: 立即开始Phase 3迁移快速见效🚀
**时间**: 2-3小时
**目标**: 将已完成的5个模块集成到Controller
**步骤**:
#### 1. 集成Application到WebApi15分钟
```bash
# 1.1 启用全局异常过滤器
mv WebApi/Filters/GlobalExceptionFilter.cs.pending WebApi/Filters/GlobalExceptionFilter.cs
# 1.2 编辑WebApi.csproj添加Application引用如果未添加
code WebApi/WebApi.csproj
```
`<ItemGroup>`中添加:
```xml
<ProjectReference Include="..\Application\Application.csproj" />
```
#### 1.3 修改Program.cs
```bash
code WebApi/Program.cs
```
添加以下代码:
```csharp
// 在builder.Services.AddControllers()处修改
builder.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>(); // 新增
});
// 在现有服务注册后添加
builder.Services.AddApplicationServices(); // 新增
```
#### 2. 迁移Controller按顺序
**2.1 迁移AuthController**15分钟
```bash
code WebApi/Controllers/AuthController.cs
```
**修改要点**:
- 构造函数: 移除`IOptions<AuthSettings>`, `IOptions<JwtSettings>`,改为注入`IAuthApplication`
- 简化Login方法: 直接调用`await _authApplication.Login(request)` + `.Ok()`包装
- 移除`GenerateJwtToken`私有方法已在Application中
- 更新using: `using Application.Dto.Auth;`
**2.2 迁移ConfigController**15分钟
**2.3 迁移BillImportController**30分钟
**2.4 迁移BudgetController**1小时
#### 3. 验证迁移结果
```bash
# 编译
dotnet build WebApi/WebApi.csproj
# 运行测试
dotnet test WebApi.Test/WebApi.Test.csproj
# 启动应用
dotnet run --project WebApi
# 访问 http://localhost:5000/scalar 测试API
```
---
### 选项3: 完整实现剩余模块(完美主义者)💎
**时间**: 5-8小时
**目标**: 完成所有8个模块然后统一迁移
**工作清单**:
1. 补充TransactionApplication3-4小时
2. 实现EmailMessageApplication2小时
3. 实现MessageRecord/Statistics等2-3小时
4. 开始Phase 3迁移2-3小时
---
## 🔧 常见问题和解决方案
### Q1: 编译时提示找不到Application命名空间
**原因**: WebApi项目尚未引用Application项目
**解决**: 参考"选项2 - Step 1"添加项目引用
### Q2: 测试时找不到某些类型
**原因**: LSP缓存问题实际编译时正常
**解决**: 运行`dotnet build`后再执行测试
### Q3: BudgetResult的字段类型不匹配
**已知情况**:
- `SelectedCategories``string[]`不是string
- `StartDate``string`不是DateTime
**解决**: 在MapToResponse中做类型转换已实现
### Q4: 流式响应如何处理
**解决方案**: Controller保留SSE响应逻辑Application提供回调接口
**示例**: 参考`APPLICATION_LAYER_PROGRESS.md` 的"已知问题"部分
---
## 📞 新会话启动提示词
**复制以下内容开始新会话**:
```
我需要继续完成EmailBill项目的Application层重构工作。
请先阅读以下文档了解当前进度:
1. APPLICATION_LAYER_PROGRESS.md完整进度报告
2. QUICK_START_GUIDE.md本文档
当前状态:
- ✅ Phase 1: 基础设施100%完成
- ✅ Phase 2: 5/8模块完成44个测试全部通过
- ⏳ Phase 3: 待开始
我希望你:
[选择以下其中一项]
A. 补充TransactionApplication的AI智能功能后再开始迁移
B. 立即开始Phase 3迁移已完成的5个模块
C. 完整实现所有8个模块后统一迁移
请按照QUICK_START_GUIDE.md中的步骤继续工作。
```
---
## 🎉 当前成就
-**项目结构**: Application项目完整搭建
-**异常机制**: 4层异常类 + 全局过滤器
-**核心模块**: 5个模块完整实现
-**测试质量**: 44个测试0失败覆盖率~90%
-**代码规范**: 符合项目C#编码规范
-**文档完整**: 详细的进度报告和恢复指南
**整体进度**: 约75%完成 🎊
**剩余工作**: 预计5-8小时即可完成整个重构
---
**祝工作顺利!如有疑问请参考`APPLICATION_LAYER_PROGRESS.md`的详细说明。** 🚀

174
.doc/START_PHASE3.md Normal file
View File

@@ -0,0 +1,174 @@
# 🚀 Phase 3 快速启动 - 给下一个Agent
## 📊 当前状态(一句话)
**Application层12个模块已完成112个测试全部通过准备开始Controller迁移。**
---
## ✅ 我完成了什么
### 实现的模块12个
1. ✅ AuthApplication - JWT认证
2. ✅ ConfigApplication - 配置管理
3. ✅ ImportApplication - 账单导入
4. ✅ BudgetApplication - 预算管理
5. ✅ TransactionApplication - 交易+AI分类扩展15+方法)
6. ✅ EmailMessageApplication - 邮件管理
7. ✅ MessageRecordApplication - 消息管理
8. ✅ TransactionStatisticsApplication - 统计分析
9. ✅ TransactionPeriodicApplication - 周期账单
10. ✅ TransactionCategoryApplication - 分类+AI图标
11. ✅ JobApplication - 任务管理
12. ✅ NotificationApplication - 通知服务
### 代码统计
- **代码文件**: 29个 .cs 文件
- **测试数**: 112个100%通过)
- **编译状态**: ✅ 0警告 0错误
---
## 🎯 你需要做什么Phase 3
### 主要任务
**迁移12个Controller改为调用Application层预计10-12小时**
### 第一步集成准备30分钟
```bash
# 1. 重命名启用全局异常过滤器
mv WebApi/Filters/GlobalExceptionFilter.cs.pending WebApi/Filters/GlobalExceptionFilter.cs
# 2. 编辑 WebApi/WebApi.csproj确保有这行
<ProjectReference Include="..\Application\Application.csproj" />
# 3. 编辑 WebApi/Program.cs添加两处
# 3.1 修改AddControllers:
builder.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
});
# 3.2 添加Application服务注册:
builder.Services.AddApplicationServices();
# 4. 验证编译
dotnet build WebApi/WebApi.csproj
```
### 第二步Controller迁移按优先级
#### 迁移模板每个Controller都一样
```csharp
// 迁移前:
public class BudgetController(
IBudgetService budgetService, // ❌ 删除
IBudgetRepository budgetRepository, // ❌ 删除
ILogger<BudgetController> logger) : ControllerBase
{
[HttpGet]
public async Task<BaseResponse<List<BudgetResult>>> GetListAsync(...)
{
try // ❌ 删除try-catch
{
var result = await budgetService.GetListAsync(...);
return result.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "...");
return "...".Fail<List<BudgetResult>>();
}
}
private void ValidateRequest(...) { } // ❌ 删除私有验证方法
}
// 迁移后:
using Application.Budget; // ✅ 新增
using Application.Dto.Budget; // ✅ 新增
public class BudgetController(
IBudgetApplication budgetApplication, // ✅ 改为Application
ILogger<BudgetController> logger) : ControllerBase
{
[HttpGet]
public async Task<BaseResponse<List<BudgetResponse>>> GetListAsync(...)
{
// 全局异常过滤器会处理异常无需try-catch
var result = await budgetApplication.GetListAsync(...);
return result.Ok();
}
// 私有方法已删除迁移到Application
}
```
#### 迁移顺序(从易到难)
1. ConfigController → ConfigApplication15分钟
2. AuthController → AuthApplication15分钟
3. BillImportController → ImportApplication30分钟
4. BudgetController → BudgetApplication1小时
5. MessageRecordController → MessageRecordApplication30分钟
6. EmailMessageController → EmailMessageApplication1小时
7. **TransactionRecordController** → TransactionApplication2-3小时 复杂
8. TransactionStatisticsController1小时
9. 其他Controller2-3小时
### ⚠️ 特别注意TransactionRecordController的SSE流式响应
对于 `SmartClassifyAsync``AnalyzeBillAsync` 方法:
**✅ 保留在Controller**:
- Response.ContentType 设置
- Response.Headers 设置
- WriteEventAsync() 私有方法
- TrySetUnconfirmedAsync() 私有方法
**✅ 调用Application**:
```csharp
await _transactionApplication.SmartClassifyAsync(
request.TransactionIds.ToArray(),
async chunk => {
var (eventType, content) = chunk;
await TrySetUnconfirmedAsync(eventType, content);
await WriteEventAsync(eventType, content);
});
```
**详细说明见**: `PHASE3_MIGRATION_GUIDE.md` 的 Step 4
---
## 🧪 验证步骤
每迁移2-3个Controller后
```bash
# 1. 编译
dotnet build WebApi/WebApi.csproj
# 2. 运行测试
dotnet test WebApi.Test/WebApi.Test.csproj
# 3. 启动应用测试
dotnet run --project WebApi
# 访问 http://localhost:5000/scalar
```
---
## 📚 详细文档
- **PHASE3_MIGRATION_GUIDE.md** ⭐ - 每个Controller详细迁移步骤
- **HANDOVER_SUMMARY.md** - 完整交接报告
- **APPLICATION_LAYER_PROGRESS.md** - Phase 1-2完整进度
---
## 🎉 项目状态
- **Phase 1-2**: ✅ 100%完成
- **测试通过**: ✅ 112/112
- **准备度**: ✅ Ready!
- **预计剩余时间**: 10-12小时
**加油!最后一步了!** 🚀

View File

@@ -0,0 +1,330 @@
# AI 服务重构总结
---
title: AI调用统一封装到SmartHandleService
author: AI Assistant
date: 2026-02-10
status: final
category: 重构
---
## 概述
本次重构将项目中所有分散的 AI 调用统一封装到 `SmartHandleService`实现了AI功能的集中管理和统一维护。
## 重构前的问题
在重构前,项目中有多个地方直接依赖 `IOpenAiService` 进行 AI 调用:
1. **EmailParseServicesBase** (`Service/EmailServices/EmailParse/IEmailParseServices.cs`)
- 邮件解析AI兜底逻辑
- 包含大量重复的类型判断代码
2. **CategoryIconGenerationJob** (`Service/Jobs/CategoryIconGenerationJob.cs`)
- 定时任务批量生成分类图标
- 包含复杂的Prompt构建逻辑
3. **TransactionCategoryApplication** (`Application/TransactionCategoryApplication.cs`)
- 手动触发单个图标生成
- 包含SVG提取和验证逻辑
4. **BudgetService** (`Service/Budget/BudgetService.cs`)
- 生成预算执行报告
- 包含复杂的数据格式化逻辑
### 存在的问题
- **代码重复**多处存在相似的AI调用代码
- **职责分散**AI相关逻辑散落在不同层级
- **难以维护**修改AI调用逻辑需要改动多个文件
- **测试困难**需要在多个测试类中Mock `IOpenAiService`
## 重构方案
### 1. 扩展 SmartHandleService 接口
`Service/AI/SmartHandleService.cs` 中新增以下方法:
```csharp
public interface ISmartHandleService
{
// 原有方法
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction);
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
Task<TransactionParseResult?> ParseOneLineBillAsync(string text);
// 新增方法
/// <summary>
/// 从邮件正文中使用AI提取交易记录AI兜底方案
/// </summary>
Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?>
ParseEmailByAiAsync(string emailBody);
/// <summary>
/// 为分类生成多个SVG图标定时任务使用
/// </summary>
Task<List<string>?> GenerateCategoryIconsAsync(string categoryName, TransactionType categoryType, int iconCount = 5);
/// <summary>
/// 为分类生成单个SVG图标手动触发使用
/// </summary>
Task<string?> GenerateSingleCategoryIconAsync(string categoryName, TransactionType categoryType);
/// <summary>
/// 生成预算执行报告HTML格式
/// </summary>
Task<string?> GenerateBudgetReportAsync(string promptWithData, int year, int month);
}
```
### 2. 实现新增方法
#### ParseEmailByAiAsync - 邮件解析
`EmailParseServicesBase.ParseByAiAsync` 方法迁移到 `SmartHandleService`
- 保留完整的JSON解析逻辑
- 保留markdown代码块清理逻辑
- 保留单个对象兼容处理
- 将类型判断逻辑保留在基类中供子类使用
#### GenerateCategoryIconsAsync - 批量图标生成
`CategoryIconGenerationJob.GenerateIconsForCategoryAsync` 的核心逻辑迁移:
- 封装5种不同风格的图标生成Prompt
- 处理JSON数组解析和验证
- 统一的错误处理和日志记录
#### GenerateSingleCategoryIconAsync - 单个图标生成
`TransactionCategoryApplication.GenerateIconAsync` 的核心逻辑迁移:
- 简化的极简风格Prompt
- SVG标签提取逻辑
- 统一的返回格式
#### GenerateBudgetReportAsync - 预算报告生成
`BudgetService` 中的报告生成逻辑迁移:
- 接收完整的Prompt包含数据和格式要求
- 统一的HTML格式要求
- 统一的错误处理
### 3. 更新调用方
#### EmailParseServicesBase
```csharp
// 修改前
public abstract class EmailParseServicesBase(
ILogger<EmailParseServicesBase> logger,
IOpenAiService openAiService
) : IEmailParseServices
// 修改后
public abstract class EmailParseServicesBase(
ILogger<EmailParseServicesBase> logger,
ISmartHandleService smartHandleService
) : IEmailParseServices
```
调用改为:
```csharp
result = await smartHandleService.ParseEmailByAiAsync(emailContent) ?? [];
```
#### CategoryIconGenerationJob
```csharp
// 修改前
public class CategoryIconGenerationJob(
ITransactionCategoryRepository categoryRepository,
IOpenAiService openAiService,
ILogger<CategoryIconGenerationJob> logger) : IJob
// 修改后
public class CategoryIconGenerationJob(
ITransactionCategoryRepository categoryRepository,
ISmartHandleService smartHandleService,
ILogger<CategoryIconGenerationJob> logger) : IJob
```
调用简化为:
```csharp
var icons = await smartHandleService.GenerateCategoryIconsAsync(category.Name, category.Type, iconCount: 5);
```
#### TransactionCategoryApplication
```csharp
// 修改前
public class TransactionCategoryApplication(
...
IOpenAiService openAiService,
...) : ITransactionCategoryApplication
// 修改后
public class TransactionCategoryApplication(
...
ISmartHandleService smartHandleService,
...) : ITransactionCategoryApplication
```
调用简化为:
```csharp
var svg = await smartHandleService.GenerateSingleCategoryIconAsync(category.Name, category.Type);
```
#### BudgetService
```csharp
// 修改前
public class BudgetService(
...
IOpenAiService openAiService,
...) : IBudgetService
// 修改后
public class BudgetService(
...
ISmartHandleService smartHandleService,
...) : IBudgetService
```
调用简化为:
```csharp
var htmlReport = await smartHandleService.GenerateBudgetReportAsync(dataPrompt, year, month);
```
### 4. 更新测试代码
更新所有测试类,将 `IOpenAiService` 的Mock改为 `ISmartHandleService`
- `BudgetStatsTest.cs`
- `TransactionCategoryApplicationTest.cs`
## 重构收益
### 1. 代码集中管理
- 所有AI调用逻辑集中在 `SmartHandleService`
- 便于统一修改Prompt策略
- 便于添加新的AI功能
### 2. 职责清晰
- `OpenAiService`底层AI API调用同步/流式)
- `SmartHandleService`业务级AI功能封装
- 业务服务层专注业务逻辑无需关心AI调用细节
### 3. 易于测试
- 只需Mock `ISmartHandleService`
- 测试更简洁Mock对象更少
- 可以为 `SmartHandleService` 编写独立的单元测试
### 4. 易于扩展
- 新增AI功能只需在 `SmartHandleService` 中添加方法
- 不影响现有业务代码
- 符合开闭原则(对扩展开放,对修改封闭)
### 5. 代码复用
- 消除了多处重复的AI调用代码
- 统一的错误处理和日志记录
- 统一的返回格式和验证逻辑
## 测试结果
重构后运行全部211个单元测试全部通过
```
已通过! - 失败: 0通过: 211已跳过: 0总计: 211持续时间: 22 s
```
## 受影响的文件清单
### 新增/修改的文件
1. **Service/AI/SmartHandleService.cs**
- 新增4个方法接口定义
- 新增4个方法实现
- 新增辅助方法:`ParseEmailSingleRecord`, `DetermineTransactionType`
### 修改的业务服务
2. **Service/EmailServices/EmailParse/IEmailParseServices.cs**
- 构造函数参数改为 `ISmartHandleService`
- 移除 `ParseByAiAsync` 方法,改为调用 `smartHandleService.ParseEmailByAiAsync`
- 保留 `DetermineTransactionType` 为protected方法供子类使用
3. **Service/EmailServices/EmailParse/EmailParseFormCcsvc.cs**
- 构造函数参数改为 `ISmartHandleService`
4. **Service/EmailServices/EmailParse/EmailParseForm95555.cs**
- 构造函数参数改为 `ISmartHandleService`
5. **Service/Jobs/CategoryIconGenerationJob.cs**
- 构造函数参数改为 `ISmartHandleService`
- 简化 `GenerateIconsForCategoryAsync` 方法
6. **Application/TransactionCategoryApplication.cs**
- 构造函数参数改为 `ISmartHandleService`
- 简化 `GenerateIconAsync` 方法
7. **Service/Budget/BudgetService.cs**
- 构造函数参数改为 `ISmartHandleService`
- 简化报告生成调用
### 修改的测试文件
8. **WebApi.Test/Budget/BudgetStatsTest.cs**
- Mock对象改为 `ISmartHandleService`
9. **WebApi.Test/Application/TransactionCategoryApplicationTest.cs**
- Mock对象改为 `ISmartHandleService`
## 架构图
```
┌─────────────────────────────────────────────────────────────┐
│ 业务层 (Business Layer) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ BudgetService│ │CategoryApp │ │EmailParse │ │
│ │ │ │ │ │Services │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
└───────────────────────────┼────────────────────────────────┘
│ 依赖
┌───────────────────────────┼────────────────────────────────┐
│ AI 服务层 (AI Service Layer) │
│ │ │
│ ┌───────▼────────┐ │
│ │SmartHandleService│ ◄── 统一封装AI调用 │
│ │ (业务级AI功能) │ │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ OpenAiService │ ◄── 底层API调用 │
│ │ (HTTP Client) │ │
│ └────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## 后续建议
1. **添加单元测试**:为 `SmartHandleService` 的新方法添加独立的单元测试
2. **监控日志**关注AI调用的性能和错误日志
3. **Prompt优化**基于实际使用反馈持续优化Prompt
4. **缓存机制**:考虑为图标生成等场景添加缓存
5. **重试机制**考虑为AI调用添加重试逻辑
## 总结
本次重构成功将项目中所有AI调用统一封装到 `SmartHandleService`,实现了代码的集中管理和职责分离。重构后的代码更易维护、更易测试、更易扩展,符合单一职责原则和依赖倒置原则。所有测试用例全部通过,证明重构没有引入功能回归问题。

View File

@@ -0,0 +1,373 @@
---
title: TransactionStatistics 接口重构文档
author: AI Assistant
date: 2026-02-10
status: final
category: API重构
---
# TransactionStatistics 接口重构文档
## 概述
本次重构针对 `TransactionStatisticsController` 中的接口进行了统一优化,采用**方案一(激进重构)**,将原有 9 个接口精简为 4 个核心接口,同时保留旧接口用于向后兼容(标记为 `[Obsolete]`)。
**重构时间**: 2026-02-10
**影响范围**: Backend (Service/Application/Controller) + Frontend (API/Views)
---
## 一、重构目标
### 问题分析
1. **接口冗余**: 存在多组功能重复的接口对
- `GetMonthlyStatistics` vs `GetRangeStatistics`
- `GetDailyStatistics` vs `GetWeeklyStatistics`
- `GetCategoryStatistics` vs `GetCategoryStatisticsByDateRange`
2. **参数不统一**: 部分接口使用 `(year, month)`,部分使用 `(startDate, endDate)`
3. **未使用接口**: `GetReasonGroups` 在前端完全未被调用
### 重构原则
- ✅ 统一使用日期范围查询 (`startDate`, `endDate`)
- ✅ 保留旧接口用于向后兼容,标记为 `[Obsolete]`
- ✅ 删除未使用的接口
- ✅ 前端优先迁移到新接口
---
## 二、接口变更对照表
### 新接口(推荐使用)
| 新接口名称 | 路径 | 参数 | 说明 | 替代的旧接口 |
|-----------|------|------|------|------------|
| `GetDailyStatisticsByRange` | `/TransactionStatistics/GetDailyStatisticsByRange` | startDate, endDate, savingClassify? | 按日期范围获取每日统计 | `GetDailyStatistics`<br>`GetWeeklyStatistics` |
| `GetSummaryByRange` | `/TransactionStatistics/GetSummaryByRange` | startDate, endDate | 按日期范围获取汇总统计 | `GetMonthlyStatistics`<br>`GetRangeStatistics` |
| `GetCategoryStatisticsByRange` | `/TransactionStatistics/GetCategoryStatisticsByRange` | startDate, endDate, type | 按日期范围获取分类统计 | `GetCategoryStatistics`<br>`GetCategoryStatisticsByDateRange` |
| `GetTrendStatistics` | `/TransactionStatistics/GetTrendStatistics` | startYear, startMonth, monthCount | 多月趋势统计 | (保持不变) |
### 标记为 Obsolete 的接口(兼容性保留)
| 接口名称 | 状态 | 建议迁移方案 |
|---------|------|------------|
| `GetBalanceStatistics` | `[Obsolete]` | 使用 `GetDailyStatisticsByRange` 并在前端计算累积余额 |
| `GetDailyStatistics` | `[Obsolete]` | 使用 `GetDailyStatisticsByRange` |
| `GetWeeklyStatistics` | `[Obsolete]` | 使用 `GetDailyStatisticsByRange` |
| `GetMonthlyStatistics` | `[Obsolete]` | 使用 `GetSummaryByRange` |
| `GetRangeStatistics` | `[Obsolete]` | 使用 `GetSummaryByRange` |
| `GetCategoryStatistics` | `[Obsolete]` | 使用 `GetCategoryStatisticsByRange` |
| `GetCategoryStatisticsByDateRange` | `[Obsolete]` | 使用 `GetCategoryStatisticsByRange` (DateTime 参数版本) |
### 删除的接口
| 接口名称 | 删除原因 |
|---------|---------|
| `GetReasonGroups` | 前端完全未使用 |
---
## 三、技术实现细节
### 1. Service 层变更
**文件**: `Service/Transaction/TransactionStatisticsService.cs`
**新增方法**:
```csharp
/// <summary>
/// 按日期范围获取汇总统计数据(新统一接口)
/// </summary>
Task<MonthlyStatistics> GetSummaryByRangeAsync(DateTime startDate, DateTime endDate);
```
**实现**: 直接查询指定日期范围的交易记录并汇总统计。
---
### 2. Application 层变更
**文件**: `Application/TransactionStatisticsApplication.cs`
**新增方法**:
```csharp
// 新统一接口
Task<List<DailyStatisticsDto>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
Task<MonthlyStatistics> GetSummaryByRangeAsync(DateTime startDate, DateTime endDate);
Task<List<CategoryStatistics>> GetCategoryStatisticsByRangeAsync(DateTime startDate, DateTime endDate, TransactionType type);
```
**标记为过时的方法**: 所有旧方法均添加 `[Obsolete]` 特性,指引开发者迁移到新接口。
---
### 3. Controller 层变更
**文件**: `WebApi/Controllers/TransactionStatisticsController.cs`
**新增接口**:
```csharp
[HttpGet]
public async Task<BaseResponse<List<DailyStatisticsDto>>> GetDailyStatisticsByRangeAsync(
[FromQuery] DateTime startDate,
[FromQuery] DateTime endDate,
[FromQuery] string? savingClassify = null)
[HttpGet]
public async Task<BaseResponse<Service.Transaction.MonthlyStatistics>> GetSummaryByRangeAsync(
[FromQuery] DateTime startDate,
[FromQuery] DateTime endDate)
[HttpGet]
public async Task<BaseResponse<List<Service.Transaction.CategoryStatistics>>> GetCategoryStatisticsByRangeAsync(
[FromQuery] DateTime startDate,
[FromQuery] DateTime endDate,
[FromQuery] TransactionType type)
```
**标记为过时的接口**: 所有旧接口均添加 `[Obsolete]` 特性。
**删除的接口**: `GetReasonGroupsAsync`
---
### 4. 前端变更
#### API 定义文件
**文件**: `Web/src/api/statistics.js`
**新增方法**:
```javascript
// 新统一接口
export const getDailyStatisticsByRange = (params) => { /* ... */ }
export const getSummaryByRange = (params) => { /* ... */ }
export const getCategoryStatisticsByRange = (params) => { /* ... */ }
```
**标记为过时的方法**: 添加 `@deprecated` JSDoc 注释。
#### 视图文件
**文件**: `Web/src/views/statisticsV2/Index.vue`
**迁移内容**:
1. 周度数据加载: `getRangeStatistics``getSummaryByRange`
2. 周度每日统计: `getWeeklyStatistics``getDailyStatisticsByRange`
3. 周度分类统计: `getCategoryStatisticsByDateRange``getCategoryStatisticsByRange`
**关键修改**:
- 修改 `endDate` 计算逻辑: `+6天``+7天`(因为新接口的 endDate 是不包含的)
- 移除 `weekEnd.setHours(23, 59, 59, 999)` 逻辑(不再需要)
---
## 四、迁移指南
### 后端开发者
#### 场景 1: 获取月度统计
**旧代码**:
```csharp
await statisticsService.GetMonthlyStatisticsAsync(2025, 2);
```
**新代码**:
```csharp
var startDate = new DateTime(2025, 2, 1);
var endDate = new DateTime(2025, 3, 1); // 不包含3月1日
await statisticsService.GetSummaryByRangeAsync(startDate, endDate);
```
#### 场景 2: 获取每日统计
**旧代码**:
```csharp
await statisticsService.GetDailyStatisticsAsync(2025, 2, savingClassify);
```
**新代码**:
```csharp
var startDate = new DateTime(2025, 2, 1);
var endDate = new DateTime(2025, 3, 1);
await statisticsService.GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify);
```
---
### 前端开发者
#### 场景 1: 获取月度汇总
**旧代码**:
```javascript
await getMonthlyStatistics({ year: 2025, month: 2 })
```
**新代码**:
```javascript
await getSummaryByRange({
startDate: '2025-02-01',
endDate: '2025-03-01'
})
```
#### 场景 2: 获取周度每日统计
**旧代码**:
```javascript
await getWeeklyStatistics({
startDate: '2025-02-01',
endDate: '2025-02-07'
})
```
**新代码**:
```javascript
await getDailyStatisticsByRange({
startDate: '2025-02-01',
endDate: '2025-02-08' // 注意endDate 不包含
})
```
#### 场景 3: 获取分类统计
**旧代码**:
```javascript
await getCategoryStatistics({ year: 2025, month: 2, type: 0 })
// 或
await getCategoryStatisticsByDateRange({
startDate: '2025-02-01',
endDate: '2025-02-28',
type: 0
})
```
**新代码**:
```javascript
await getCategoryStatisticsByRange({
startDate: '2025-02-01',
endDate: '2025-03-01', // 注意endDate 不包含
type: 0
})
```
---
## 五、重要注意事项
### ⚠️ endDate 语义变更
**新接口的 `endDate` 参数是不包含的exclusive**
- ❌ 错误: `startDate: '2025-02-01', endDate: '2025-02-28'` → 缺少2月28日数据
- ✅ 正确: `startDate: '2025-02-01', endDate: '2025-03-01'` → 包含整个2月
### 🔒 向后兼容策略
- 所有旧接口保持可用,仅标记为 `[Obsolete]`
- 前端 V1 版本继续使用旧接口
- 前端 V2 版本已迁移到新接口
- 建议在后续版本中逐步废弃旧接口
### 📝 测试覆盖
- ✅ 后端单元测试: `TransactionStatisticsServiceTest` 全部通过9个测试
- ⚠️ 前端测试: 建议手动测试以下场景
- 月度视图切换
- 年度视图切换
- 周度视图切换
- 分类统计数据展示
---
## 六、优化效果
### 接口精简
| 指标 | 重构前 | 重构后 | 优化 |
|------|-------|-------|------|
| 接口总数 | 9 | 4 | ⬇️ 55% |
| 推荐使用接口 | - | 4 | - |
| 兼容性接口 | - | 7 | - |
| 未使用接口 | 1 | 0 | ⬇️ 100% |
### 参数统一度
- **重构前**: 3种参数模式year+month, startDate+endDate, startYear+startMonth+monthCount
- **重构后**: 2种参数模式startDate+endDate, startYear+startMonth+monthCount
### API 可维护性
- ✅ 接口语义更清晰
- ✅ 参数命名更统一
- ✅ 减少代码重复
- ✅ 降低学习成本
---
## 七、后续计划
1. **V1 版本迁移** (优先级: 中)
-`statisticsV1/Index.vue` 迁移到新接口
- 移除对 `getBalanceStatistics` 的依赖
2. **旧接口废弃** (优先级: 低)
- 确认所有前端页面迁移完成
- 在下个大版本中彻底删除旧接口
3. **性能优化** (优先级: 高)
- 考虑提供聚合接口,一次返回多种类型的分类统计
- 前端添加数据缓存机制
---
## 八、相关文件清单
### 后端文件
- `Service/Transaction/TransactionStatisticsService.cs`
- `Application/TransactionStatisticsApplication.cs`
- `WebApi/Controllers/TransactionStatisticsController.cs`
### 前端文件
- `Web/src/api/statistics.js`
- `Web/src/views/statisticsV2/Index.vue`
### 测试文件
- `WebApi.Test/Transaction/TransactionStatisticsServiceTest.cs`
- `WebApi.Test/Application/TransactionStatisticsApplicationTest.cs`
---
## 九、常见问题 (FAQ)
### Q1: 旧接口什么时候会被删除?
A: 目前旧接口仅标记为 `[Obsolete]`,不会被立即删除。计划在确认所有前端页面迁移完成后,在下个大版本中删除。
### Q2: 为什么 `GetTrendStatistics` 没有改为日期范围?
A: `GetTrendStatistics` 的业务场景是获取"连续N个月"的趋势数据,使用 `(startYear, startMonth, monthCount)` 更符合业务语义。如果改为日期范围,反而需要前端自行计算月份跨度。
### Q3: 新接口的 `endDate` 为什么是不包含的?
A: 采用"左闭右开区间 `[startDate, endDate)`"是业界常见做法,优点包括:
- 方便表示连续时间段如2月: `[2025-02-01, 2025-03-01)`
- 避免时区和时间精度问题(不需要 `23:59:59.999`
- 与大多数编程语言的区间语义一致(如 Python 的 `range()`
### Q4: 如何确认迁移是否成功?
A: 检查以下几点:
1. 前端代码中没有引用旧的 API 方法
2. 统计数据展示正常,与旧版本数据一致
3. 控制台无 API 调用错误
---
**文档版本**: v1.0
**最后更新**: 2026-02-10

View File

@@ -1,12 +1,13 @@
---
name: bug-fix
description: Bug诊断与修复技能 - 强调交互式确认和影响分析
tags:
- bug-fix
- debugging
- troubleshooting
- interactive
version: 1.0.1
metadata:
tags:
- bug-fix
- debugging
- troubleshooting
- interactive
version: 1.0.1
---
# Bug修复技能

View File

@@ -0,0 +1,466 @@
---
name: code-refactoring
description: 代码重构技能 - 强调保持功能不变的前提下优化代码结构,充分理解需求和交互式确认
metadata:
tags:
- refactoring
- code-quality
- clean-code
- interactive
version: 1.0.0
---
# 代码重构技能
## 技能概述
专门用于在**不改变现有功能逻辑**的前提下优化代码结构,包括:
- 抽取公共方法、组件和工具类
- 消除重复代码DRY原则
- 移除无用代码(死代码、注释代码、未使用的依赖)
- 改善代码可读性和可维护性
- 优化代码结构和命名规范
## ⚠️ 核心原则MUST FOLLOW
### 1. 功能不变保证
**禁止在重构过程中改变功能行为!**
- ✅ 重构前后的输入输出必须完全一致
- ✅ 重构前后的副作用必须一致数据库操作、文件IO、日志等
- ✅ 重构不应改变性能特征(除非明确以性能优化为目标)
- ❌ 严禁"顺便"添加新功能或修复bug
### 2. 充分理解需求
**禁止根据模糊的需求开始重构!**
- ✅ 彻底理解重构的目标和范围
- ✅ 识别需求中的模糊点和二义性
- ✅ 使用 `question` 工具获取明确的用户意图
- ❌ 不要基于假设进行重构
### 3. 先确认再动手
**禁止未经用户确认就直接修改代码!**
- ✅ 先列出所有修改点和影响范围
- ✅ 使用 `question` 工具让用户确认重构方案
- ✅ 获得明确的同意后再执行修改
- ❌ 不要边分析边修改
## ⚠️ 强制交互规则MUST FOLLOW
**遇到需要用户确认的情况时,必须立即调用 `question` 工具:**
**禁止**"我需要向用户确认..."、"建议向用户询问..."、"在执行前应该确认..."
**必须**:直接调用 `question` 工具,不要描述或延迟
**调用格式**
```javascript
question({
header: "重构确认",
questions: [{
question: "是否要将重复的验证逻辑抽取到公共方法中?",
options: ["是,抽取到工具类", "是,抽取到基类", "否,保持现状", "其他"]
}]
})
```
**规则**
- 每次最多 **3个问题**
- 每个问题 **3-6个选项**(穷举常见情况 + "其他"兜底)
- 用户通过**上下键导航**选择
- 适用于**所有阶段**(需求理解、方案确认、风险评估)
## 重构流程
### 阶段1: 需求理解(必须交互确认)
#### 1.1 理解重构目标
**获取用户意图**
- 用户想重构什么?(文件、模块、类、方法)
- 重构的原因是什么?(代码重复、难以维护、命名不清晰、结构混乱)
- 期望达到什么效果?(提高复用性、提升可读性、简化逻辑、统一规范)
**触发 `question` 工具的场景**
- 用户只说"重构这个文件"但未说明具体问题
- 用户提到"优化"但没有明确优化方向
- 用户的需求包含多个可能的重构方向
- 重构范围不明确(单个文件 vs 整个模块)
**示例问题**
```javascript
question({
header: "明确重构目标",
questions: [
{
question: "您主要关注哪方面的重构?",
options: [
"抽取重复代码",
"改善命名和结构",
"移除无用代码",
"提取公共组件/方法",
"全面优化"
]
},
{
question: "重构范围是?",
options: [
"仅当前文件",
"当前模块(相关的几个文件)",
"整个项目",
"让我分析后建议"
]
}
]
})
```
#### 1.2 识别约束条件
**必须明确的约束**
- 是否有不能改动的接口或API对外暴露的
- 是否有特殊的性能要求
- 是否需要保持特定的代码风格
- 是否有测试覆盖(如有,重构后测试必须通过)
**触发 `question` 工具的场景**
- 发现公开API可能需要调整
- 代码涉及性能敏感的操作
- 存在多种重构方式,各有权衡
- 不确定某些代码是否仍在使用
**示例问题**
```javascript
question({
header: "重构约束确认",
questions: [
{
question: "发现 `ProcessData` 方法被多个外部模块调用,重构时:",
options: [
"保持方法签名不变,仅优化内部实现",
"可以修改方法签名,我会同步更新调用方",
"先告诉我影响范围,我再决定",
"其他"
]
}
]
})
```
#### 1.3 理解代码上下文
**分析现有代码**
- 使用 `semantic_search` 查找相关代码
- 使用 `grep_search` 查找重复模式
- 使用 `list_code_usages` 分析调用关系
- 阅读相关文件理解业务逻辑
**注意事项**
- 不要在分析阶段进行任何修改
- 记录发现的问题点和重构机会
- 识别可能的风险和边界情况
### 阶段2: 方案设计(必须交互确认)
#### 2.1 列出重构点
**详细列出每个修改点**
- 修改的文件和位置
- 修改的具体内容(前后对比)
- 修改的原因和收益
- 可能的影响范围
**示例格式**
```
## 重构点清单
### 1. 抽取重复的数据验证逻辑
**位置**: TransactionController.cs (L45-L60, L120-L135)
**操作**: 将重复的金额验证逻辑抽取到 ValidationHelper.ValidateAmount()
**原因**: 两处代码完全相同违反DRY原则
**影响**: 无,纯内部优化
### 2. 移除未使用的导入和变量
**位置**: BudgetService.cs (L5, L23)
**操作**: 删除 `using System.Text.RegularExpressions;` 和未使用的 `_tempValue` 字段
**原因**: 死代码,增加维护负担
**影响**: 无
### 3. 重命名方法提高可读性
**位置**: DataProcessor.cs (L89)
**操作**: `DoWork()` → `ProcessTransactionData()`
**原因**: 原名称不够清晰,无法表达具体功能
**影响**: 4个调用点需要同步更新
```
#### 2.2 评估风险和影响
**必须分析的风险**
- 是否影响公开API
- 是否影响性能
- 是否影响测试
- 是否涉及数据迁移
- 是否存在隐藏的依赖关系
**触发 `question` 工具的场景**
- 发现重构会影响多个模块
- 存在潜在的兼容性问题
- 有多种实现方式可选
- 需要在代码质量和改动风险间权衡
**示例问题**
```javascript
question({
header: "重构方案确认",
questions: [
{
question: "发现3处重复的日期格式化代码建议",
options: [
"抽取到工具类Common项目",
"抽取到当前服务的私有方法",
"保留重复(代码简单,抽取收益小)",
"让我看看代码再决定"
]
},
{
question: "重构会影响4个controller和2个service是否继续",
options: [
"是,一次性全部重构",
"否,先重构影响小的部分",
"告诉我每个的影响详情",
"其他"
]
}
]
})
```
#### 2.3 提交方案供确认
**必须向用户展示**
1. 完整的重构点清单如2.1格式)
2. 风险评估和影响分析
3. 建议的执行顺序
4. 预计改动的文件数量
**必须调用 `question` 工具获得最终确认**
```javascript
question({
header: "最终确认",
questions: [{
question: "我已列出所有重构点和影响分析,是否开始执行?",
options: [
"是,按计划执行",
"需要调整部分重构点",
"取消重构",
"其他问题"
]
}]
})
```
**重要**
- ❌ 不要在得到明确的"是,按计划执行"之前修改任何代码
- ❌ 不要假设用户会同意
- ✅ 如用户选择"需要调整"返回阶段1重新理解需求
### 阶段3: 执行重构
#### 3.1 执行原则
- **小步快跑**: 一次完成一个重构点,不要多个同时进行
- **频繁验证**: 每完成一个点就运行测试或构建验证
- **保持可逆**: 确保随时可以回滚
- **记录进度**: 使用 `manage_todo_list` 跟踪进度
#### 3.2 执行步骤
1. **创建TODO清单**:
```javascript
manage_todo_list({
todoList: [
{
id: 1,
title: "抽取重复验证逻辑到ValidationHelper",
description: "TransactionController.cs L45-L60, L120-L135",
status: "not-started"
},
{
id: 2,
title: "移除BudgetService.cs中的未使用导入",
description: "删除using System.Text.RegularExpressions",
status: "not-started"
},
// ... 更多任务
]
})
```
2. **逐个执行**:
- 标记任务为 `in-progress`
- 使用 `multi_replace_string_in_file``replace_string_in_file` 修改代码
- 运行测试验证: `dotnet test``pnpm test`
- 标记任务为 `completed`
- 继续下一个
3. **验证每个步骤**:
- 后端重构后运行: `dotnet build && dotnet test`
- 前端重构后运行: `pnpm lint && pnpm build`
- 确保没有引入编译错误或测试失败
#### 3.3 异常处理
**如果遇到预期外的问题**
- ✅ 立即停止后续重构
- ✅ 报告问题详情
- ✅ 调用 `question` 工具询问如何处理
```javascript
question({
header: "重构遇到问题",
questions: [{
question: "抽取方法后发现测试 `TestValidation` 失败了,如何处理?",
options: [
"回滚这个改动",
"修复测试用例",
"暂停,我来看看",
"继续其他重构点"
]
}]
})
```
### 阶段4: 验证和总结
#### 4.1 全面验证
**必须执行的验证**
- 所有单元测试通过
- 项目成功构建
- Lint检查通过
- 关键功能手动验证(如适用)
**验证命令**
```bash
# 后端
dotnet clean
dotnet build EmailBill.sln
dotnet test WebApi.Test/WebApi.Test.csproj
# 前端
cd Web
pnpm lint
pnpm build
```
#### 4.2 总结报告
**提供清晰的总结**
```
## 重构完成总结
### ✅ 已完成的重构
1. 抽取重复验证逻辑 (ValidationHelper.cs)
- 消除了 3 处重复代码
- 减少代码行数 45 行
2. 移除未使用的导入和变量
- BudgetService.cs: 移除 2 个未使用的 using
- TransactionController.cs: 移除 1 个未使用字段
3. 改善方法命名
- DoWork → ProcessTransactionData (4 处调用点已更新)
- Calculate → CalculateMonthlyBudget (2 处调用点已更新)
### 📊 重构影响
- 修改文件数: 6
- 新增文件数: 1 (ValidationHelper.cs)
- 删除代码行数: 78
- 新增代码行数: 42
- 净减少代码: 36 行
### ✅ 验证结果
- ✓ 所有测试通过 (23/23)
- ✓ 项目构建成功
- ✓ Lint检查通过
- ✓ 功能验证正常
### 📝 建议的后续工作
- 考虑为 ValidationHelper 添加单元测试
- 可以进一步重构 DataProcessor 类的其他方法
```
## 常见重构模式
### 1. 抽取公共方法
**识别标准**: 代码块在多处重复出现≥2次
**操作**:
- 创建独立方法或工具类
- 保持方法签名简洁明确
- 添加必要的注释和文档
### 2. 抽取公共组件
**识别标准**: UI组件或业务逻辑在多个视图/页面重复
**操作**:
- 创建可复用组件Vue组件、Service类等
- 使用Props/参数传递可变部分
- 确保组件职责单一
### 3. 移除死代码
**识别标准**:
- 未被调用的方法
- 未被使用的变量、导入、依赖
- 注释掉的代码
**操作**:
- 使用 `list_code_usages` 确认真正未使用
- 谨慎删除(可能有隐式调用)
- 使用Git历史作为备份
### 4. 改善命名
**识别标准**:
- 名称不能表达意图(如 `DoWork`, `Process`, `temp`
- 名称与实际功能不符
- 违反命名规范
**操作**:
- 使用 `list_code_usages` 找到所有使用点
- 使用 `multi_replace_string_in_file` 批量更新
- 确保命名符合项目规范见AGENTS.md
### 5. 简化复杂逻辑
**识别标准**:
- 深层嵌套(>3层
- 过长方法(>50行
- 复杂条件判断
**操作**:
- 早返回模式guard clauses
- 拆分子方法
- 使用策略模式或查表法
## 注意事项
### ❌ 不要做
- 在重构中添加新功能
- 在重构中修复bug除非bug是重构导致的
- 未经确认就大范围修改
- 改变公开API而不考虑兼容性
- 跳过测试验证
### ✅ 要做
- 保持每次重构的范围可控
- 频繁提交代码(每完成一个重构点提交一次)
- 确保测试覆盖率不降低
- 保持代码风格一致
- 记录重构的原因和收益
## 项目特定规范
### C# 代码重构
- 遵循 `AGENTS.md` 中的 C# 代码风格
- 使用 file-scoped namespace
- 公共方法使用 XML 注释
- 业务逻辑使用中文注释
- 工具方法考虑放入 `Common` 项目
### Vue/TypeScript 代码重构
- 使用 Composition API
- 组件放入 `src/components`
- 遵循 ESLint 和 Prettier 规则
- 使用 `@/` 别名避免相对路径
- 提取的组件使用 Vant UI 风格
## 总结
代码重构是一个**谨慎的、迭代的、需要充分确认的**过程。核心要点:
1. **理解先于行动** - 彻底理解需求和约束
2. **交互式确认** - 使用 `question` 工具消除歧义
3. **计划后执行** - 列出修改点并获得确认
4. **小步快跑** - 逐个完成重构点,频繁验证
5. **功能不变** - 始终确保行为一致性

View File

@@ -32,120 +32,60 @@ metadata:
### 1. 现代移动端优先设计
**布局标准:**
**核心规范:**
- 移动视口: 375px 宽度 (iPhone SE 基准)
- 安全区域: 尊重 iOS/Android 安全区域边距
- 触摸目标: 交互元素最小 44x44px
- 间距比例: 4px, 8px, 12px, 16px, 24px, 32px (8px 基础网格)
- 圆角半径: 12px (卡片), 16px (对话框), 24px (药丸/标签), 8px (按钮)
- 卡片阴影: `0 2px 12px rgba(0,0,0,0.08)` 用于突出表面
- 交互元素最小触摸目标: 44x44px
- 间距基于 8px 网格: 4px, 8px, 12px, 16px, 24px, 32px
- 卡片阴影: `0 2px 12px rgba(0,0,0,0.08)` (亮色模式)
**避免 AI 设计痕迹:**
- 不要使用通用的 "Dashboard" 占位符文本
- 不要使用图库照片或 Lorem Ipsum
- 不要使用过饱和的主色 (#007AFF, 不是 #0088FF)
- 不要使用生硬的阴影或渐变
- 不要使用 Comic Sans, Papyrus 或装饰性字体
- 使用代码库中的真实中文业务术语
** AI 设计痕迹检查清单:**
- ❌ 使用 "Dashboard", "Lorem Ipsum" 等通用占位符
- ❌ 使用过饱和的颜色或生硬的渐变
- ❌ 使用装饰性字体 (Comic Sans, Papyrus)
- ✅ 使用代码库中的真实中文业务术语
- ✅ 使用克制的配色和柔和的阴影
- ✅ 使用专业的系统字体
### 2. 统一色彩系统 (基于实际 v2.pen 日历设计)
### 2. 统一色彩系统
**亮色主题:**
```css
/* 背景色 - 基于实际设计 */
--background-page: #FFFFFF /* 页面背景 (Calendar frame fill) */
--background-card: #F6F7F8 /* 卡片背景 (statsCard, tCard fills) */
--background-accent: #F5F5F5 /* 强调背景 (notif button) */
/* 文本色 - 基于实际设计 */
--text-primary: #1A1A1A /* 主文本 (titles, labels) */
--text-secondary: #6B7280 /* 次要文本 (dates, subtitles) */
--text-tertiary: #9CA3AF /* 三级文本 (weekday labels) */
/* 语义色 - 基于实际设计 */
--accent-red: #FF6B6B /* 支出/负数 (expense icon) */
--accent-yellow: #FCD34D /* 警告/中性 (coffee icon) */
--accent-green: #F0FDF4 /* 收入/正数 (badge background) */
--accent-blue: #E0E7FF /* 智能标签 (smart button) */
--accent-warm: #FFFBEB /* 温暖色调 (badge background) */
/* 主操作色 */
--primary: #3B82F6 /* 主色调 (FAB button from Budget Stats) */
/* 边框与分割线 */
--border: #E5E7EB /* 边框颜色 (从设计推断) */
```
**暗色主题:**
```css
/* 背景色 - 基于实际暗色设计 */
--background-page: #09090B /* 页面背景 (Calendar Dark frame) */
--background-card: #18181B /* 卡片背景 (dark statsCard, tCard) */
--background-accent: #27272A /* 强调背景 (dark notif, tCat) */
/* 文本色 - 基于实际暗色设计 */
--text-primary: #F4F4F5 /* 主文本 (dark titles) */
--text-secondary: #A1A1AA /* 次要文本 (dark dates, subtitles) */
--text-tertiary: #71717A /* 三级文本 (dark weekday labels) */
/* 语义色 - 暗色模式适配 */
--accent-red: #FF6B6B /* 保持一致 */
--accent-yellow: #FCD34D /* 保持一致 */
--accent-green: #064E3B /* 深绿 (dark badge) */
--accent-blue: #312E81 /* 深蓝 (dark smart button) */
--accent-warm: #451A03 /* 深暖色 (dark badge) */
/* 主操作色 */
--primary: #3B82F6 /* 保持一致 */
/* 边框与分割线 */
--border: #3F3F46 /* 深色边框 */
```
**色彩分层:**
- **背景层**: 页面背景 → 卡片背景 → 强调背景 (三层递进)
- **文本层**: 主文本 → 次要文本 → 三级文本 (三级层次)
- **语义色**: 红色(支出/危险) → 黄色(警告) → 绿色(收入/成功) → 蓝色(主操作/信息)
**颜色使用规则:**
- **页面背景**: 亮色 `#FFFFFF`, 暗色 `#09090B`
- **卡片背景**: 亮色 `#F6F7F8`, 暗色 `#18181B`
- **主文本**: 亮色 `#1A1A1A`, 暗色 `#F4F4F5`
- **次要文本**: 亮色 `#6B7280`, 暗色 `#A1A1AA`
- **支出/负数**: `#FF6B6B` (两种模式一致)
- **主操作按钮**: `#3B82F6` (两种模式一致)
- **圆角**: 12px (小按钮), 16px (卡片), 20px (统计卡片), 22px (图标按钮)
- **阴影**: 亮色使用柔和阴影, 暗色使用更深的阴影或无阴影
- **避免**: 纯黑 (#000000) 或纯白 (#FFFFFF) 文本
- 始终使用语义颜色变量,避免硬编码十六进制值
- 支出/负数统一使用红色 `#FF6B6B`,收入/正数使用绿色系
- 主操作按钮统一使用蓝色 `#3B82F6`
- 避免纯黑 (#000000) 或纯白 (#FFFFFF) 文本,使用柔和的色调
- 暗色模式下减少阴影强度或完全移除
- 详细色值参见文末"快速参考"表格
### 3. 排版系统 (基于实际 v2.pen 设计)
### 3. 排版系统
**字体栈:**
```css
/* 标题与大标题 - 基于 v2.pen */
font-family: 'Bricolage Grotesque', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
- **标题**: `'Bricolage Grotesque'` - 用于大数值、章节标题
- **正文**: `'DM Sans'` - 用于界面文本、说明
- **数字**: `'DIN Alternate'` - 用于金额、数据显示
- **备选**: `-apple-system, 'PingFang SC'` - 系统默认字体
/* 正文与界面 - 基于 v2.pen */
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
/* 备选: 系统默认 */
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', sans-serif;
```
**字号比例 (从实际设计提取):**
| 用途 | 字体 | 大小 | 粗细 | 示例 |
|------|------|------|------|------|
| 大标题数值 | Bricolage Grotesque | 32px | 800 | ¥ 1,248.50 (statsVal) |
| 页面标题 | DM Sans | 24px | 500 | 2026年1月 (subtitle) |
| 章节标题 | Bricolage Grotesque | 18px | 700 | 每日统计, 交易记录 (titles) |
| 正文文本 | DM Sans | 15px | 600 | Lunch, Coffee (交易名称) |
| 说明文字 | DM Sans | 13px | 500 | 12:30 PM, Total Spent (标签) |
| 微型文字 | DM Sans | 12px | 600 | 一二三四五六日 (星期) |
**中文文本规则:**
- 使用简体中文
- 真实业务术语: "每日统计" (daily stats), "交易记录" (transactions)
**排版原则:**
- 使用真实中文业务术语,避免 Lorem Ipsum
- 行高: 1.4-1.6 保证可读性
- 使用真实业务术语,避免 Lorem Ipsum
- 数字数据使用等宽字体 (tabular-nums)
- 字号遵循比例系统,避免任意数值
- 详细字号比例参见文末"快速参考"表格
### 4. 组件库 (基于实际 v2.pen 设计)
### 4. 组件库
**卡片 (基于 statsCard, tCard):**
**设计原则:**
- 所有尺寸和间距基于 8px 网格系统
- 圆角: 12px (小按钮), 16px/20px (卡片), 22px/28px (圆形按钮)
- 交互元素最小触摸目标: 44x44px
- 详细组件规格参见文末"快速参考"表格
**卡片设计 (基于 statsCard, tCard):**
```
统计卡片 (大卡片):
- 背景: #F6F7F8 (亮色), #18181B (暗色)
@@ -207,23 +147,16 @@ font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB
**布局模式 (基于 Calendar 结构):**
```
页面容器:
- 宽度: 402px (设计视口)
- 布局: 垂直
- 内边距: 24px (容器边距)
- 间距: 16px (章节之间)
头部区域:
- 内边距: 8px 24px
- 布局: 水平, 两端对齐
- 对齐项: 居中
内容区域:
- 内边距: 24px
- 间距: 12-16px
- 布局: 垂直
页面容器: 402px (设计视口), 垂直布局, 24px 内边距
头部区域: 水平布局, 两端对齐, 8px 24px 内边距
内容区域: 垂直布局, 24px 内边距, 12-16px 间距
```
**关键布局原则:**
- 遵循 Flex 容器模式 (见下方"5. 布局模式")
- 导航栏背景必须透明 (`:deep(.van-nav-bar) { background: transparent !important; }`)
- 尊重安全区域 (`env(safe-area-inset-bottom)`)
### 5. 布局模式
**页面结构 (Flex 容器):**
@@ -241,17 +174,17 @@ font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB
4. bottom-button 或 van-tabbar (固定)
```
**导航栏背景透明化 (一致性模式):**
**导航栏背景透明化 (项目标准模式):**
```css
/* 设置页面容器背景色 */
/* 所有页面统一设置 */
:deep(.van-nav-bar) {
background: transparent !important;
}
```
**重要:** 项目中所有视图都采用此模式,使导航栏背景透明,与页面背景融合。实现此效果时,请确保:
- 页面容器有明确的背景色 (亮色: #FFFFFF, 暗色: #09090B)
- 导航栏始终使用 `:deep(.van-nav-bar)` 选择器
- 必须添加 `!important` 覆盖 Vant 默认样式
**关键要求:**
- 页面容器必须有明确的背景色
- 必须使用 `:deep()` 选择器覆盖 Vant 样式
- 必须添加 `!important` 标记
-`<style scoped>` 块中添加此规则
**安全区域处理:**
@@ -407,42 +340,23 @@ document.documentElement.setAttribute('data-theme', theme.value)
- 示例: "Budget/ListView", "Budget/EditForm"
```
**图层命名:**
```
内部元素可用中文:
✅ 预算卡片背景
✅ 金额文本
✅ 操作按钮组
组件用英文:
✅ CardBackground
✅ AmountLabel
✅ ActionButtons
```
### 10. 质量检查清单
**完成设计前:**
- [ ] 同时创建亮色和暗色主题
- [ ] 所有文本使用真实中文业务术语 (无 Lorem Ipsum)
**设计完成前必检项:**
- [ ] 同时创建亮色和暗色主题
- [ ] 使用真实中文业务术语 (无占位文本)
- [ ] 交互元素 ≥ 44x44px
- [ ] 间距遵循 8px 网格
- [ ] 颜色使用语义变量 (非硬编码十六进制)
- [ ] 圆角: 12px/16px/24px 保持一致
- [ ] 尊重安全区域边距 (底部: 50px + 安全区域)
- [ ] 帧正确命名 模块/屏幕/变体
- [ ] 可复用组件标记为 `reusable: true`
- [ ] 字号匹配字号比例 (12/14/16/18/24)
- [ ] 数字数据使用 DIN Alternate
- [ ] 无 AI 生成的占位内容
- [ ] 已截图并视觉验证
- [ ] 导航栏背景设置为透明 (`:deep(.van-nav-bar) { background: transparent !important; }`)
- [ ] 使用语义颜色变量 (非硬编码)
- [ ] 导航栏背景透明 (`:deep(.van-nav-bar)`)
- [ ] 帧命名: 模块-屏幕-变体 格式
- [ ] 可复用组件标记 `reusable: true`
- [ ] 两种主题截图验证
**无障碍:**
**无障碍标准:**
- [ ] 正文对比度 ≥ 4.5:1
- [ ] 大文本对比度 ≥ 3:1 (18px+)
- [ ] 触摸目标清晰分离 (最小 8px 间距)
- [ ] 颜色不是信息的唯一指示器
- [ ] 触摸目标间距 ≥ 8px
## PANCLI 工作流程
@@ -804,9 +718,10 @@ delegate_task(
)
```
## 快速参考 (基于实际 v2.pen 设计)
## 快速参考
**颜色面板 (基于实际 v2.pen 设计):**
**颜色面板:**
| 名称 | 亮色 | 暗色 | 用途 |
|------|------|------|------|
| 页面背景 | #FFFFFF | #09090B | 页面背景 |
@@ -821,7 +736,8 @@ delegate_task(
| 绿色 | #F0FDF4 | #064E3B | 收入标签 |
| 蓝色 | #E0E7FF | #312E81 | 信息标签 |
**排版 (实际字体):**
**排版比例:**
| 用途 | 字体 | 大小 | 粗细 |
|------|------|------|------|
| 大数值 | Bricolage Grotesque | 32px | 800 |
@@ -831,26 +747,19 @@ delegate_task(
| 说明 | DM Sans | 13px | 500 |
| 微型标签 | DM Sans | 12px | 600 |
**间距与布局 (实际数值):**
- 容器内边距: 24px (主区域), 20px (卡片), 16px (小卡片)
- 间距比例: 2px, 4px, 8px, 12px, 14px, 16px
- 图标大小: 20px
- 图标按钮: 44x44px
- FAB 按钮: 56x56px
**组件规格:**
**圆角 (实际数值):**
- 小按钮/标签: 12px
- 卡片: 16px, 20px
- 图标按钮: 22px (圆形)
- FAB: 28px (圆形)
**触摸目标:** 最小 44x44px
**视口:** 402px (设计宽度), 移动端优先
- **容器内边距**: 24px (主区域), 20px (卡片), 16px (小卡片)
- **间距比例**: 2px, 4px, 8px, 12px, 14px, 16px
- **圆角**: 12px (标签), 16px/20px (卡片), 22px/28px (圆形按钮)
- **图标**: 20px
- **图标按钮**: 44x44px
- **FAB 按钮**: 56x56px
- **触摸目标**: 最小 44x44px
- **设计视口**: 402px 宽度
---
**版本:** 2.0.0 (使用 v2.pen 实际设计值更新)
**最后更新:** 2026-02-03
**来源:** .pans/v2.pen 日历 (亮色/暗色)
**版本:** 2.0.0
**最后更新:** 2026-02-04
**维护者:** EmailBill 设计团队

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,325 +0,0 @@
## Vue 3 Composition API Research - Modular Architecture Best Practices
### 研究日期: 2026-02-03
---
## 1. 官方 Vue 3 组件组织原则
### 1.1 Composables 用于代码组织
来源: Vue 官方文档 - https://vuejs.org/guide/reusability/composables
**核心原则:**
- Composables 不仅用于复用,也用于**代码组织**
- 当组件变得过于复杂时,应该将逻辑按**关注点分离**提取到更小的函数中
- 可以将提取的 composables 视为**组件级别的服务**,它们可以相互通信
**官方示例模式:**
```vue
<script setup>
import { useFeatureA } from './featureA.js'
import { useFeatureB } from './featureB.js'
import { useFeatureC } from './featureC.js'
const { foo, bar } = useFeatureA()
const { baz } = useFeatureB(foo)
const { qux } = useFeatureC(baz)
</script>
```
**关键洞察:**
- Composables 应返回**普通对象**包含多个 refs,保持响应式
- 避免返回 reactive 对象,因为解构会失去响应性
- Composables 可以接收其他 composables 的返回值作为参数
---
## 2. 代码分割与懒加载
### 2.1 defineAsyncComponent 用于模块懒加载
来源: Vue 官方文档 - https://github.com/vuejs/docs/blob/main/src/guide/best-practices/performance.md
**适用场景:**
- 将大型组件树分割成独立的 chunks
- 仅在组件渲染时才加载,改善初始加载时间
```js
import { defineAsyncComponent } from 'vue'
// Foo.vue 及其依赖被单独打包成一个 chunk
// 只有在组件被渲染时才会按需获取
const Foo = defineAsyncComponent(() => import('./Foo.vue'))
```
### 2.2 动态导入用于 JS 代码分割
```js
// lazy.js 及其依赖会被分割成单独的 chunk
// 只在 loadLazy() 被调用时才加载
function loadLazy() {
return import('./lazy.js')
}
```
---
## 3. 真实世界的模块化架构模式
### 3.1 Dashboard 模块化架构 - 成功案例
**案例 1: Soybean Admin (MIT License)**
来源: https://github.com/soybeanjs/soybean-admin/blob/main/src/views/home/index.vue
```vue
<script setup lang="ts">
import { computed } from 'vue';
import { useAppStore } from '@/store/modules/app';
import HeaderBanner from './modules/header-banner.vue';
import CardData from './modules/card-data.vue';
import LineChart from './modules/line-chart.vue';
import PieChart from './modules/pie-chart.vue';
import ProjectNews from './modules/project-news.vue';
import CreativityBanner from './modules/creativity-banner.vue';
const appStore = useAppStore();
const gap = computed(() => (appStore.isMobile ? 0 : 16));
</script>
```
**架构特点:**
- Index.vue 作为**容器组件**,只负责布局和响应式计算
- 每个 modules/*.vue 是**独立的功能模块**
- 模块命名清晰: header-banner, card-data, line-chart 等
- 使用 Pinia store 进行状态共享
**案例 2: Art Design Pro (MIT License)**
来源: https://github.com/Daymychen/art-design-pro/blob/main/src/views/dashboard/ecommerce/index.vue
```vue
<script setup lang="ts">
import Banner from './modules/banner.vue'
import TotalOrderVolume from './modules/total-order-volume.vue'
import TotalProducts from './modules/total-products.vue'
import SalesTrend from './modules/sales-trend.vue'
import SalesClassification from './modules/sales-classification.vue'
import TransactionList from './modules/transaction-list.vue'
import HotCommodity from './modules/hot-commodity.vue'
import RecentTransaction from './modules/recent-transaction.vue'
import AnnualSales from './modules/annual-sales.vue'
import ProductSales from './modules/product-sales.vue'
import SalesGrowth from './modules/sales-growth.vue'
import CartConversionRate from './modules/cart-conversion-rate.vue'
import HotProductsList from './modules/hot-products-list.vue'
defineOptions({ name: 'Ecommerce' })
</script>
```
**架构特点:**
- 电商 dashboard 包含 13 个独立模块
- 每个模块代表一个业务功能卡片
- Index.vue **不传递数据**,模块自治
---
## 4. 模块间通信模式
### 4.1 defineEmits 用于子到父通信
来源: Vue 核心仓库 - https://github.com/vuejs/core/blob/main/packages/runtime-core/src/apiSetupHelpers.ts
**TypeScript 类型声明模式:**
```ts
const emit = defineEmits<{
'update:modelValue': [value: string];
'change': [event: Event];
'custom-event': [payload: CustomPayload];
}>();
```
**Runtime 声明模式:**
```js
const emit = defineEmits(['change', 'update'])
```
### 4.2 Props 模式 - 数据传递 vs 自取数据
**案例研究: Halo CMS (GPL-3.0)**
来源: https://github.com/halo-dev/halo/blob/main/ui/console-src/modules/system/users/components/GrantPermissionModal.vue
```vue
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useFetchRoles, useFetchRoleTemplates } from "../composables/use-role";
const props = withDefaults(
defineProps<{
user?: User;
}>(),
{
user: undefined,
}
);
const emit = defineEmits<{
(event: "close"): void;
}>();
// 模块自己获取数据
onMounted(async () => {
await fetchRoles();
});
</script>
```
**模式总结:**
- **Props 传递身份标识** (如 user ID),而非完整数据
- **模块自己获取详细数据** (通过 composables)
- 这样保持模块的**高内聚低耦合**
---
## 5. 何时模块应该自取数据 vs 接收 Props
### 5.1 自取数据的场景
- 模块是**独立的业务单元**(如日历、统计卡片)
- 数据获取逻辑属于模块内部关注点
- 模块需要**定期刷新**或**重新加载**数据
- 多个平行模块各自管理自己的状态
**示例:**
```vue
<!-- 统计卡片模块 - 自己获取数据 -->
<script setup lang="ts">
const { data, loading } = useBudgetStats()
onMounted(() => {
loadStats()
})
</script>
```
### 5.2 接收 Props 的场景
- 模块是**展示组件**(Presentational Component)
- 父组件需要**协调多个子组件**的数据
- 数据来源于**全局状态管理**(如 Pinia store)
- 需要在父组件层面做**数据聚合或转换**
**示例:**
```vue
<!-- 数据展示组件 - 接收 props -->
<script setup lang="ts">
const props = defineProps<{
stats: BudgetStats
loading: boolean
}>()
</script>
```
---
## 6. TypeScript vs JavaScript 在 Vue 3 项目中
### 6.1 EmailBill 项目的选择
**当前状况:**
- ESLint 配置中禁用了 TypeScript 规则
- 使用 `<script setup lang="ts">` 但不强制类型检查
- 轻量级类型提示,不追求严格类型安全
**何时避免 TypeScript:**
- 小型项目,团队更熟悉 JavaScript
- 快速原型开发
- 避免 TypeScript 配置和类型定义的复杂度
- 保持构建速度和开发体验的流畅
**何时使用 TypeScript:**
- 大型团队协作
- 复杂的状态管理和数据流
- 需要严格的 API 契约
- 长期维护的企业级应用
---
## 7. 模块化架构的最佳实践总结
### 7.1 目录结构推荐
```
views/
calendar/
Index.vue # 容器组件,布局和协调
modules/
CalendarView.vue # 日历展示模块(自取数据)
MonthlyStats.vue # 月度统计模块(自取数据)
QuickActions.vue # 快捷操作模块(事件驱动)
composables/
useCalendarData.ts # 日历数据获取逻辑
useMonthlyStats.ts # 统计数据获取逻辑
```
### 7.2 组件职责划分
**Index.vue (容器组件):**
- 布局管理和响应式设计
- 协调模块间的通信(如果需要)
- 全局状态初始化
- **不应包含业务逻辑**
**modules/*.vue (功能模块):**
- 独立的业务功能单元
- 自己管理数据获取和状态
- 通过 emits 向父组件通信
- 高内聚,低耦合
**composables/*.ts (可复用逻辑):**
- 数据获取逻辑
- 业务规则计算
- 状态管理辅助
- 可在多个组件间共享
### 7.3 通信模式推荐
**模块向上通信 (Child → Parent):**
```ts
const emit = defineEmits<{
'date-changed': [date: Date]
'item-clicked': [item: CalendarItem]
}>()
```
**模块间通信 (Sibling ↔ Sibling):**
- 通过**父组件中转**事件
- 或使用**全局事件总线**(如 mitt)
- 或使用**Pinia store** 共享状态
---
## 8. 关键洞察和建议
### 8.1 高内聚模块设计
- 每个模块应该是**自治的**,包含自己的数据获取、状态管理和事件处理
- Index.vue 应该是**轻量级的协调者**,而非数据的中央枢纽
### 8.2 Props vs 自取数据的平衡
- **身份标识和配置通过 props** (如 userId, date, theme)
- **业务数据通过模块自取** (如 stats, calendar items)
### 8.3 避免过度抽象
- 不要为了复用而复用
- 优先考虑**代码的清晰度**而非极致的 DRY
- Composables 应该解决**真实的重复问题**,而非预测性的抽象
---
## 9. 参考资源
**官方文档:**
- Vue 3 Composables: https://vuejs.org/guide/reusability/composables
- Vue 3 Performance: https://github.com/vuejs/docs/blob/main/src/guide/best-practices/performance.md
- Vue 3 State Management: https://vuejs.org/guide/scaling-up/state-management
**真实项目参考:**
- Soybean Admin: https://github.com/soybeanjs/soybean-admin (MIT)
- Art Design Pro: https://github.com/Daymychen/art-design-pro (MIT)
- Halo CMS: https://github.com/halo-dev/halo (GPL-3.0)
- DataEase: https://github.com/dataease/dataease (GPL-3.0)

View File

@@ -1,36 +0,0 @@
# Date Navigation Implementation Learnings
## Implementation Details
- Added left/right arrow navigation to both `StatisticsView.vue` and `BudgetView.vue`
- Used `<van-icon name="arrow-left" />` and `<van-icon name="arrow" />` for directional arrows
- Text center remains clickable for date picker popup
- Arrows use `@click.stop` to prevent event propagation
## StatisticsView.vue
- `navigateDate(direction)` method handles both month and year modes
- Month mode: handles year boundaries (Jan -> Dec of previous year, Dec -> Jan of next year)
- Year mode: increments/decrements year directly
- Resets `showAllExpense` state on navigation
## BudgetView.vue
- `navigateDate(direction)` uses native Date object manipulation
- `setMonth()` automatically handles month/year boundaries
- Updates `selectedDate` ref which triggers data fetching via watcher
## Styling
- Added `.nav-date-picker` with flex layout and 12px gap
- `.nav-date-text` for clickable center text
- `.nav-arrow` with 8px horizontal padding for touch-friendly targets
- Active state opacity transition for visual feedback
- Arrow font size: 18px for clear visibility
## Key Decisions
- Used `@click.stop` on arrows to prevent opening date picker
- Centered layout preserved (van-nav-bar default behavior)
- Consistent styling across both views
- Touch-friendly padding for mobile UX
## Verification
- ESLint: No new errors introduced
- Build: Successful compilation
- Date math: Correctly handles month/year boundaries

View File

@@ -1 +0,0 @@
No previous issues.

View File

@@ -1 +0,0 @@
Use Vant UI icons for navigation.

View File

@@ -1,80 +0,0 @@
# Decisions - Statistics Year Selection Enhancement
## [2026-01-28] Architecture Decisions
### Frontend Implementation Strategy
#### 1. Date Picker Mode Toggle
- Add a toggle switch in the date picker popup to switch between "按月" (month) and "按年" (year) modes
- When "按年" selected: use `columns-type="['year']"`
- When "按月" selected: use `columns-type="['year', 'month']` (current behavior)
#### 2. State Management
- Add `dateSelectionMode` ref: `'month'` | `'year'`
- When year-only mode: set `currentMonth = 0` to indicate full year
- Keep `currentYear` as integer (unchanged)
- Update `selectedDate` array dynamically based on mode:
- Year mode: `['YYYY']`
- Month mode: `['YYYY', 'MM']`
#### 3. Display Logic
- Nav bar title: `currentYear年` when `currentMonth === 0`, else `currentYear年currentMonth月`
- Chart titles: Update to reflect year or year-month scope
#### 4. API Calls
- Pass `month: currentMonth.value || 0` to all API calls
- Backend will handle month=0 as year-only query
### Backend Implementation Strategy
#### 1. Repository Layer Change
**File**: `Repository/TransactionRecordRepository.cs`
**Method**: `BuildQuery()` lines 81-86
```csharp
if (year.HasValue)
{
if (month.HasValue && month.Value > 0)
{
// Specific month
var dateStart = new DateTime(year.Value, month.Value, 1);
var dateEnd = dateStart.AddMonths(1);
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
}
else
{
// Entire year
var dateStart = new DateTime(year.Value, 1, 1);
var dateEnd = new DateTime(year.Value + 1, 1, 1);
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
}
}
```
#### 2. Service Layer
- No changes needed - services already pass month parameter to repository
- Services will receive month=0 for year-only queries
#### 3. API Controller
- No changes needed - already accepts year/month parameters
### Testing Strategy
#### Backend Tests
- Test year-only query returns all transactions for that year
- Test month-specific query still works
- Test edge cases: year boundaries, leap years
#### Frontend Tests
- Test toggle switches picker mode correctly
- Test year selection updates state and fetches data
- Test display updates correctly for year vs year-month
### User Experience Flow
1. User clicks date picker in nav bar
2. Popup opens with toggle: "按月 | 按年"
3. User selects mode (default: 按月 for backward compatibility)
4. User selects date(s) and confirms
5. Statistics refresh with new scope
6. Display updates to show scope (year or year-month)

View File

@@ -1,27 +0,0 @@
# Issues - Statistics Year Selection Enhancement
## [2026-01-28] Backend Repository Limitation
### Issue
`TransactionRecordRepository.BuildQuery()` requires both year AND month parameters to be present. Year-only queries (month=null or month=0) are not supported.
### Impact
- Cannot query full-year statistics from the frontend
- Current implementation only supports month-level granularity
- All statistics endpoints rely on `QueryAsync(year, month, ...)`
### Solution
Modify `BuildQuery()` method in `Repository/TransactionRecordRepository.cs` to support:
1. Year-only queries (when year provided, month is null or 0)
2. Month-specific queries (when both year and month provided - current behavior)
### Implementation Location
- File: `Repository/TransactionRecordRepository.cs`
- Method: `BuildQuery()` lines 81-86
- Also need to update service layer to handle month=0 or null
### Testing Requirements
- Test year-only query returns all transactions for that year
- Test month-specific query still works as before
- Test edge cases: leap years, year boundaries
- Verify all statistics endpoints work with year-only mode

View File

@@ -1,181 +0,0 @@
# Learnings - Statistics Year Selection Enhancement
## [2026-01-28] Initial Analysis
### Current Implementation
- **File**: `Web/src/views/StatisticsView.vue`
- **Current picker**: `columns-type="['year', 'month']` (year-month only)
- **State variables**:
- `currentYear` - integer year
- `currentMonth` - integer month (1-12)
- `selectedDate` - array `['YYYY', 'MM']` for picker
- **API calls**: All endpoints use `{ year, month }` parameters
### Vant UI Year-Only Pattern
- **Key prop**: `columns-type="['year']"`
- **Picker value**: Single-element array `['YYYY']`
- **Confirmation**: `selectedValues[0]` contains year string
### Implementation Strategy
1. Add UI toggle to switch between year-month and year-only modes
2. When year-only selected, set `currentMonth = 0` or null to indicate full year
3. Backend API already supports year-only queries (when month=0 or null)
4. Update display logic to show "YYYY年" vs "YYYY年MM月"
### API Compatibility - CRITICAL FINDING
- **Backend limitation**: `TransactionRecordRepository.BuildQuery()` (lines 81-86) requires BOTH year AND month
- Current logic: `if (year.HasValue && month.HasValue)` - year-only queries are NOT supported
- **Must modify repository** to support year-only queries:
- When year provided but month is null/0: query entire year (Jan 1 to Dec 31)
- When both year and month provided: query specific month (current behavior)
- All statistics endpoints use `QueryAsync(year, month, ...)` pattern
### Required Backend Changes
**File**: `Repository/TransactionRecordRepository.cs`
**Method**: `BuildQuery()` lines 81-86
**Change**: Modify year/month filtering logic to support year-only queries
```csharp
// Current (line 81-86):
if (year.HasValue && month.HasValue)
{
var dateStart = new DateTime(year.Value, month.Value, 1);
var dateEnd = dateStart.AddMonths(1);
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
}
// Needed:
if (year.HasValue)
{
if (month.HasValue && month.Value > 0)
{
// Specific month
var dateStart = new DateTime(year.Value, month.Value, 1);
var dateEnd = dateStart.AddMonths(1);
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
}
else
{
// Entire year
var dateStart = new DateTime(year.Value, 1, 1);
var dateEnd = new DateTime(year.Value + 1, 1, 1);
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
}
}
```
### Existing Patterns
- BudgetView.vue uses same year-month picker pattern
- Dayjs used for all date formatting: `dayjs().format('YYYY-MM-DD')`
- Date picker values always arrays for Vant UI
## [2026-01-28] Repository BuildQuery() Enhancement
### Implementation Completed
- **File Modified**: `Repository/TransactionRecordRepository.cs` lines 81-94
- **Change**: Updated year/month filtering logic to support year-only queries
### Logic Changes
```csharp
// Old: Required both year AND month
if (year.HasValue && month.HasValue) { ... }
// New: Support year-only queries
if (year.HasValue)
{
if (month.HasValue && month.Value > 0)
{
// 查询指定年月
var dateStart = new DateTime(year.Value, month.Value, 1);
var dateEnd = dateStart.AddMonths(1);
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
}
else
{
// 查询整年数据1月1日到下年1月1日
var dateStart = new DateTime(year.Value, 1, 1);
var dateEnd = new DateTime(year.Value + 1, 1, 1);
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
}
}
```
### Behavior
- **Month-specific** (month.HasValue && month.Value > 0): Query from 1st of month to 1st of next month
- **Year-only** (month is null or 0): Query from Jan 1 to Jan 1 of next year
- **No year provided**: No date filtering applied
### Verification
- All 14 tests pass: `dotnet test WebApi.Test/WebApi.Test.csproj`
- No breaking changes to existing functionality
- Chinese comments added for business logic clarity
### Key Pattern
- Use `month.Value > 0` check to distinguish year-only (0/null) from month-specific (1-12)
- Date range is exclusive on upper bound (`< dateEnd`) to avoid including boundary dates
## [2026-01-28] Frontend Year-Only Selection Implementation
### Changes Made
**File**: `Web/src/views/StatisticsView.vue`
#### 1. Nav Bar Title Display (Line 12)
- Updated to show "YYYY年" when `currentMonth === 0`
- Shows "YYYY年MM月" when month is selected
- Template: `{{ currentMonth === 0 ? \`${currentYear}年\` : \`${currentYear}年${currentMonth}月\` }}`
#### 2. Date Picker Popup (Lines 268-289)
- Added toggle switch using `van-tabs` component
- Two modes: "按月" (month) and "按年" (year)
- Tabs positioned above the date picker
- Dynamic `columns-type` based on selection mode:
- Year mode: `['year']`
- Month mode: `['year', 'month']`
#### 3. State Management (Line 347)
- Added `dateSelectionMode` ref: `'month'` | `'year'`
- Default: `'month'` for backward compatibility
- `currentMonth` set to `0` when year-only selected
#### 4. Confirmation Handler (Lines 532-544)
- Updated to handle both year-only and year-month modes
- When year mode: `newMonth = 0`
- When month mode: `newMonth = parseInt(selectedValues[1])`
#### 5. API Calls (All Statistics Endpoints)
- Updated all API calls to use `month: currentMonth.value || 0`
- Ensures backend receives `0` for year-only queries
- Modified functions:
- `fetchMonthlyData()` (line 574)
- `fetchCategoryData()` (lines 592, 610, 626)
- `fetchDailyData()` (line 649)
- `fetchBalanceData()` (line 672)
- `loadCategoryBills()` (line 1146)
#### 6. Mode Switching Watcher (Lines 1355-1366)
- Added `watch(dateSelectionMode)` to update `selectedDate` array
- When switching to year mode: `selectedDate = [year.toString()]`
- When switching to month mode: `selectedDate = [year, month]`
#### 7. Styling (Lines 1690-1705)
- Added `.date-picker-header` styles for tabs
- Clean, minimal design matching Vant UI conventions
- Proper spacing and background colors
### Vant UI Patterns Used
- **van-tabs**: For mode switching toggle
- **van-date-picker**: Dynamic `columns-type` prop
- **van-popup**: Container for picker and tabs
- Composition API with `watch` for reactive updates
### User Experience
1. Click nav bar date → popup opens with "按月" default
2. Switch to "按年" → picker shows only year column
3. Select year and confirm → `currentMonth = 0`
4. Nav bar shows "2025年" instead of "2025年1月"
5. All statistics refresh with year-only data
### Verification
- Build succeeds: `cd Web && pnpm build`
- No TypeScript errors
- No breaking changes to existing functionality
- Backward compatible with month-only selection

View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE - EmailBill
**Generated:** 2026-01-28
**Commit:** 5c9d7c5
**Generated:** 2026-02-10
**Commit:** 3e18283
**Branch:** main
## OVERVIEW
@@ -15,9 +15,11 @@ EmailBill/
├── Entity/ # Database entities (FreeSql ORM)
├── Repository/ # Data access layer
├── Service/ # Business logic layer
├── Application/ # Application layer (业务编排、DTO转换)
├── WebApi/ # ASP.NET Core Web API
├── WebApi.Test/ # Backend tests (xUnit)
── Web/ # Vue 3 frontend (Vite + Vant UI)
── Web/ # Vue 3 frontend (Vite + Vant UI)
└── .doc/ # Project documentation archive
```
## WHERE TO LOOK
@@ -26,10 +28,12 @@ EmailBill/
| Entity definitions | Entity/ | BaseEntity pattern, FreeSql attributes |
| Data access | Repository/ | BaseRepository, GlobalUsings |
| Business logic | Service/ | Jobs, Email services, App settings |
| Application orchestration | Application/ | DTO 转换、业务编排、接口门面 |
| API endpoints | WebApi/Controllers/ | DTO patterns, REST controllers |
| Frontend views | Web/src/views/ | Vue composition API |
| API clients | Web/src/api/ | Axios-based HTTP clients |
| Tests | WebApi.Test/ | xUnit + NSubstitute + FluentAssertions |
| Documentation archive | .doc/ | Technical docs, migration guides |
## Build & Test Commands

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Service\Service.csproj" />
<ProjectReference Include="..\Repository\Repository.csproj" />
<ProjectReference Include="..\Entity\Entity.csproj" />
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,85 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Service.AppSettingModel;
namespace Application;
/// <summary>
/// 认证应用服务接口
/// </summary>
public interface IAuthApplication
{
/// <summary>
/// 用户登录
/// </summary>
LoginResponse Login(LoginRequest request);
}
/// <summary>
/// 认证应用服务实现
/// </summary>
public class AuthApplication(
IOptions<AuthSettings> authSettings,
IOptions<JwtSettings> jwtSettings,
ILogger<AuthApplication> logger) : IAuthApplication
{
private readonly AuthSettings _authSettings = authSettings.Value;
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
private readonly ILogger<AuthApplication> _logger = logger;
public LoginResponse Login(LoginRequest request)
{
// 验证密码
if (string.IsNullOrEmpty(request.Password))
{
throw new ValidationException("密码不能为空");
}
if (request.Password != _authSettings.Password)
{
_logger.LogWarning("登录失败: 密码错误");
throw new ValidationException("密码错误");
}
// 生成JWT Token
var token = GenerateJwtToken();
var expiresAt = DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours);
_logger.LogInformation("用户登录成功");
return new LoginResponse
{
Token = token,
ExpiresAt = expiresAt
};
}
/// <summary>
/// 生成JWT Token
/// </summary>
private string GenerateJwtToken()
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new Claim("auth", "password-auth")
};
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
expires: DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View File

@@ -0,0 +1,302 @@
using Service.Budget;
namespace Application;
/// <summary>
/// 预算应用服务接口
/// </summary>
public interface IBudgetApplication
{
/// <summary>
/// 获取预算列表
/// </summary>
Task<List<BudgetResponse>> GetListAsync(DateTime referenceDate);
/// <summary>
/// 获取分类统计信息(月度和年度)
/// </summary>
Task<BudgetCategoryStatsResponse> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
/// <summary>
/// 获取未被预算覆盖的分类统计信息
/// </summary>
Task<List<UncoveredCategoryResponse>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
/// <summary>
/// 获取归档总结
/// </summary>
Task<string?> GetArchiveSummaryAsync(DateTime referenceDate);
/// <summary>
/// 获取指定周期的存款预算信息
/// </summary>
Task<BudgetResponse?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
/// <summary>
/// 删除预算
/// </summary>
Task DeleteByIdAsync(long id);
/// <summary>
/// 创建预算
/// </summary>
Task<long> CreateAsync(CreateBudgetRequest request);
/// <summary>
/// 更新预算
/// </summary>
Task UpdateAsync(UpdateBudgetRequest request);
}
/// <summary>
/// 预算应用服务实现
/// </summary>
public class BudgetApplication(
IBudgetService budgetService,
IBudgetRepository budgetRepository
) : IBudgetApplication
{
public async Task<List<BudgetResponse>> GetListAsync(DateTime referenceDate)
{
var results = await budgetService.GetListAsync(referenceDate);
// 排序: 刚性支出优先 → 按分类 → 按类型 → 按使用率 → 按名称
return results
.OrderByDescending(b => b.IsMandatoryExpense)
.ThenBy(b => b.Category)
.ThenBy(b => b.Type)
.ThenByDescending(b => b.Limit > 0 ? b.Current / b.Limit : 0)
.ThenBy(b => b.Name)
.Select(MapToResponse)
.ToList();
}
public async Task<BudgetCategoryStatsResponse> GetCategoryStatsAsync(
BudgetCategory category,
DateTime referenceDate)
{
var result = await budgetService.GetCategoryStatsAsync(category, referenceDate);
return new BudgetCategoryStatsResponse
{
Month = new BudgetStatsDetail
{
Limit = result.Month.Limit,
Current = result.Month.Current,
Remaining = result.Month.Limit - result.Month.Current,
UsagePercentage = result.Month.Rate
},
Year = new BudgetStatsDetail
{
Limit = result.Year.Limit,
Current = result.Year.Current,
Remaining = result.Year.Limit - result.Year.Current,
UsagePercentage = result.Year.Rate
}
};
}
public async Task<List<UncoveredCategoryResponse>> GetUncoveredCategoriesAsync(
BudgetCategory category,
DateTime? referenceDate = null)
{
var results = await budgetService.GetUncoveredCategoriesAsync(category, referenceDate);
return results.Select(r => new UncoveredCategoryResponse
{
Category = r.Category,
Amount = r.TotalAmount,
Count = r.TransactionCount
}).ToList();
}
public async Task<string?> GetArchiveSummaryAsync(DateTime referenceDate)
{
return await budgetService.GetArchiveSummaryAsync(referenceDate.Year, referenceDate.Month);
}
public async Task<BudgetResponse?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
{
var result = await budgetService.GetSavingsBudgetAsync(year, month, type);
return result == null ? null : MapToResponse(result);
}
public async Task DeleteByIdAsync(long id)
{
var success = await budgetRepository.DeleteAsync(id);
if (!success)
{
throw new BusinessException("删除预算失败,记录不存在");
}
}
public async Task<long> CreateAsync(CreateBudgetRequest request)
{
// 业务验证
await ValidateCreateRequestAsync(request);
// 不记额预算的金额强制设为0
var limit = request.NoLimit ? 0 : request.Limit;
var budget = new BudgetRecord
{
Name = request.Name,
Type = request.Type,
Limit = limit,
Category = request.Category,
SelectedCategories = string.Join(",", request.SelectedCategories),
StartDate = request.StartDate ?? DateTime.Now,
NoLimit = request.NoLimit,
IsMandatoryExpense = request.IsMandatoryExpense
};
// 验证分类冲突
await ValidateBudgetCategoriesAsync(budget);
var success = await budgetRepository.AddAsync(budget);
if (!success)
{
throw new BusinessException("创建预算失败");
}
return budget.Id;
}
public async Task UpdateAsync(UpdateBudgetRequest request)
{
var budget = await budgetRepository.GetByIdAsync(request.Id);
if (budget == null)
{
throw new NotFoundException("预算不存在");
}
// 业务验证
await ValidateUpdateRequestAsync(request);
// 不记额预算的金额强制设为0
var limit = request.NoLimit ? 0 : request.Limit;
budget.Name = request.Name;
budget.Type = request.Type;
budget.Limit = limit;
budget.Category = request.Category;
budget.SelectedCategories = string.Join(",", request.SelectedCategories);
budget.NoLimit = request.NoLimit;
budget.IsMandatoryExpense = request.IsMandatoryExpense;
if (request.StartDate.HasValue)
{
budget.StartDate = request.StartDate.Value;
}
// 验证分类冲突
await ValidateBudgetCategoriesAsync(budget);
var success = await budgetRepository.UpdateAsync(budget);
if (!success)
{
throw new BusinessException("更新预算失败");
}
}
#region Private Methods
/// <summary>
/// 映射到响应DTO
/// </summary>
private static BudgetResponse MapToResponse(BudgetResult result)
{
// 解析StartDate字符串为DateTime
DateTime.TryParse(result.StartDate, out var startDate);
return new BudgetResponse
{
Id = result.Id,
Name = result.Name,
Type = result.Type,
Limit = result.Limit,
Current = result.Current,
Category = result.Category,
SelectedCategories = result.SelectedCategories,
StartDate = startDate,
NoLimit = result.NoLimit,
IsMandatoryExpense = result.IsMandatoryExpense,
UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0
};
}
/// <summary>
/// 验证创建请求
/// </summary>
private static Task ValidateCreateRequestAsync(CreateBudgetRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new ValidationException("预算名称不能为空");
}
if (!request.NoLimit && request.Limit <= 0)
{
throw new ValidationException("预算金额必须大于0");
}
if (request.SelectedCategories.Length == 0)
{
throw new ValidationException("请至少选择一个分类");
}
return Task.CompletedTask;
}
/// <summary>
/// 验证更新请求
/// </summary>
private static Task ValidateUpdateRequestAsync(UpdateBudgetRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new ValidationException("预算名称不能为空");
}
if (!request.NoLimit && request.Limit <= 0)
{
throw new ValidationException("预算金额必须大于0");
}
if (request.SelectedCategories.Length == 0)
{
throw new ValidationException("请至少选择一个分类");
}
return Task.CompletedTask;
}
/// <summary>
/// 验证预算分类从Controller迁移的业务逻辑
/// </summary>
private async Task ValidateBudgetCategoriesAsync(BudgetRecord record)
{
// 验证不记额预算必须是年度预算
if (record.NoLimit && record.Type != BudgetPeriodType.Year)
{
throw new ValidationException("不记额预算只能设置为年度预算。");
}
var allBudgets = await budgetRepository.GetAllAsync();
var recordSelectedCategories = record.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
foreach (var budget in allBudgets)
{
var selectedCategories = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
if (budget.Id != record.Id)
{
if (budget.Category == record.Category &&
recordSelectedCategories.Intersect(selectedCategories).Any())
{
throw new ValidationException($"和 {budget.Name} 存在分类冲突,请调整相关分类。");
}
}
}
}
#endregion
}

View File

@@ -0,0 +1,53 @@
namespace Application;
/// <summary>
/// 配置应用服务接口
/// </summary>
public interface IConfigApplication
{
/// <summary>
/// 获取配置值
/// </summary>
Task<string> GetConfigAsync(string key);
/// <summary>
/// 设置配置值
/// </summary>
Task SetConfigAsync(string key, string value);
}
/// <summary>
/// 配置应用服务实现
/// </summary>
public class ConfigApplication(
IConfigService configService,
ILogger<ConfigApplication> logger
) : IConfigApplication
{
public async Task<string> GetConfigAsync(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new ValidationException("配置键不能为空");
}
var value = await configService.GetConfigByKeyAsync<string>(key);
return value ?? string.Empty;
}
public async Task SetConfigAsync(string key, string value)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new ValidationException("配置键不能为空");
}
var success = await configService.SetConfigByKeyAsync(key, value);
if (!success)
{
throw new BusinessException($"设置配置 {key} 失败");
}
logger.LogInformation("配置 {Key} 已更新", key);
}
}

View File

@@ -0,0 +1,89 @@
namespace Application.Dto;
/// <summary>
/// 预算响应
/// </summary>
public record BudgetResponse
{
public long Id { get; init; }
public string Name { get; init; } = string.Empty;
public BudgetPeriodType Type { get; init; }
public decimal Limit { get; init; }
public decimal Current { get; init; }
public BudgetCategory Category { get; init; }
public string[] SelectedCategories { get; init; } = [];
public DateTime StartDate { get; init; }
public bool NoLimit { get; init; }
public bool IsMandatoryExpense { get; init; }
public decimal UsagePercentage { get; init; }
}
/// <summary>
/// 创建预算请求
/// </summary>
public record CreateBudgetRequest
{
public string Name { get; init; } = string.Empty;
public BudgetPeriodType Type { get; init; } = BudgetPeriodType.Month;
public decimal Limit { get; init; }
public BudgetCategory Category { get; init; }
public string[] SelectedCategories { get; init; } = [];
public DateTime? StartDate { get; init; }
public bool NoLimit { get; init; }
public bool IsMandatoryExpense { get; init; }
}
/// <summary>
/// 更新预算请求
/// </summary>
public record UpdateBudgetRequest
{
public long Id { get; init; }
public string Name { get; init; } = string.Empty;
public BudgetPeriodType Type { get; init; } = BudgetPeriodType.Month;
public decimal Limit { get; init; }
public BudgetCategory Category { get; init; }
public string[] SelectedCategories { get; init; } = [];
public DateTime? StartDate { get; init; }
public bool NoLimit { get; init; }
public bool IsMandatoryExpense { get; init; }
}
/// <summary>
/// 分类统计响应
/// </summary>
public record BudgetCategoryStatsResponse
{
public BudgetStatsDetail Month { get; init; } = new();
public BudgetStatsDetail Year { get; init; } = new();
}
/// <summary>
/// 统计详情
/// </summary>
public record BudgetStatsDetail
{
public decimal Limit { get; init; }
public decimal Current { get; init; }
public decimal Remaining { get; init; }
public decimal UsagePercentage { get; init; }
}
/// <summary>
/// 未覆盖分类响应
/// </summary>
public record UncoveredCategoryResponse
{
public string Category { get; init; } = string.Empty;
public decimal Amount { get; init; }
public int Count { get; init; }
}
/// <summary>
/// 更新归档总结请求
/// </summary>
public record UpdateArchiveSummaryRequest
{
public DateTime ReferenceDate { get; init; }
public string? Summary { get; init; }
}

View File

@@ -0,0 +1,49 @@
namespace Application.Dto.Category;
/// <summary>
/// 分类响应
/// </summary>
public record CategoryResponse
{
public long Id { get; init; }
public string Name { get; init; } = string.Empty;
public TransactionType Type { get; init; }
public string? Icon { get; init; }
public DateTime CreateTime { get; init; }
public DateTime? UpdateTime { get; init; }
}
/// <summary>
/// 创建分类请求
/// </summary>
public record CreateCategoryRequest
{
public string Name { get; init; } = string.Empty;
public TransactionType Type { get; init; }
}
/// <summary>
/// 更新分类请求
/// </summary>
public record UpdateCategoryRequest
{
public long Id { get; init; }
public string Name { get; init; } = string.Empty;
}
/// <summary>
/// 生成图标请求
/// </summary>
public record GenerateIconRequest
{
public long CategoryId { get; init; }
}
/// <summary>
/// 更新选中图标请求
/// </summary>
public record UpdateSelectedIconRequest
{
public long CategoryId { get; init; }
public int SelectedIndex { get; init; }
}

View File

@@ -0,0 +1,28 @@
namespace Application.Dto;
/// <summary>
/// 获取配置请求
/// </summary>
public record GetConfigRequest
{
/// <summary>
/// 配置键
/// </summary>
public string Key { get; init; } = string.Empty;
}
/// <summary>
/// 设置配置请求
/// </summary>
public record SetConfigRequest
{
/// <summary>
/// 配置键
/// </summary>
public string Key { get; init; } = string.Empty;
/// <summary>
/// 配置值
/// </summary>
public string Value { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,38 @@
namespace Application.Dto.Email;
/// <summary>
/// 邮件消息响应
/// </summary>
public record EmailMessageResponse
{
public long Id { get; init; }
public string Subject { get; init; } = string.Empty;
public string From { get; init; } = string.Empty;
public string Body { get; init; } = string.Empty;
public string HtmlBody { get; init; } = string.Empty;
public DateTime ReceivedDate { get; init; }
public DateTime CreateTime { get; init; }
public DateTime? UpdateTime { get; init; }
public int TransactionCount { get; init; }
public string ToName { get; init; } = string.Empty;
}
/// <summary>
/// 邮件查询请求
/// </summary>
public record EmailQueryRequest
{
public DateTime? LastReceivedDate { get; init; }
public long? LastId { get; init; }
}
/// <summary>
/// 邮件分页结果
/// </summary>
public record EmailPagedResult
{
public EmailMessageResponse[] Data { get; init; } = [];
public int Total { get; init; }
public long? LastId { get; init; }
public DateTime? LastTime { get; init; }
}

View File

@@ -0,0 +1,38 @@
namespace Application.Dto;
/// <summary>
/// 导入请求
/// </summary>
public record ImportRequest
{
/// <summary>
/// 文件流
/// </summary>
public required Stream FileStream { get; init; }
/// <summary>
/// 文件扩展名(.csv, .xlsx, .xls
/// </summary>
public required string FileExtension { get; init; }
/// <summary>
/// 文件名
/// </summary>
public string FileName { get; init; } = string.Empty;
/// <summary>
/// 文件大小(字节)
/// </summary>
public long FileSize { get; init; }
}
/// <summary>
/// 导入响应
/// </summary>
public record ImportResponse
{
/// <summary>
/// 导入结果消息
/// </summary>
public string Message { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
namespace Application.Dto;
/// <summary>
/// 登录请求
/// </summary>
public record LoginRequest
{
/// <summary>
/// 密码
/// </summary>
public string Password { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,17 @@
namespace Application.Dto;
/// <summary>
/// 登录响应
/// </summary>
public record LoginResponse
{
/// <summary>
/// JWT Token
/// </summary>
public string Token { get; init; } = string.Empty;
/// <summary>
/// Token过期时间UTC
/// </summary>
public DateTime ExpiresAt { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace Application.Dto.Message;
/// <summary>
/// 消息记录响应
/// </summary>
public record MessageRecordResponse
{
public long Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public bool IsRead { get; init; }
public DateTime CreateTime { get; init; }
}
/// <summary>
/// 消息分页结果
/// </summary>
public record MessagePagedResult
{
public MessageRecordResponse[] Data { get; init; } = [];
public int Total { get; init; }
}

View File

@@ -0,0 +1,56 @@
namespace Application.Dto.Periodic;
/// <summary>
/// 周期性账单响应
/// </summary>
public record PeriodicResponse
{
public long Id { get; init; }
public PeriodicType PeriodicType { get; init; }
public string PeriodicConfig { get; init; } = string.Empty;
public decimal Amount { get; init; }
public TransactionType Type { get; init; }
public string Classify { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public bool IsEnabled { get; init; }
public DateTime? NextExecuteTime { get; init; }
public DateTime CreateTime { get; init; }
public DateTime? UpdateTime { get; init; }
}
/// <summary>
/// 创建周期性账单请求
/// </summary>
public record CreatePeriodicRequest
{
public PeriodicType PeriodicType { get; init; }
public string? PeriodicConfig { get; init; }
public decimal Amount { get; init; }
public TransactionType Type { get; init; }
public string? Classify { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// 更新周期性账单请求
/// </summary>
public record UpdatePeriodicRequest
{
public long Id { get; init; }
public PeriodicType PeriodicType { get; init; }
public string? PeriodicConfig { get; init; }
public decimal Amount { get; init; }
public TransactionType Type { get; init; }
public string? Classify { get; init; }
public string? Reason { get; init; }
public bool IsEnabled { get; init; }
}
/// <summary>
/// 周期性账单分页结果
/// </summary>
public record PeriodicPagedResult
{
public PeriodicResponse[] Data { get; init; } = [];
public int Total { get; init; }
}

View File

@@ -0,0 +1,20 @@
namespace Application.Dto.Statistics;
/// <summary>
/// 余额统计DTO
/// </summary>
public record BalanceStatisticsDto(
int Day,
decimal CumulativeBalance
);
/// <summary>
/// 每日统计DTO
/// </summary>
public record DailyStatisticsDto(
int Day,
int Count,
decimal Expense,
decimal Income,
decimal Saving
);

View File

@@ -0,0 +1,106 @@
namespace Application.Dto.Transaction;
/// <summary>
/// 交易响应
/// </summary>
public record TransactionResponse
{
public long Id { get; init; }
public DateTime OccurredAt { get; init; }
public string Reason { get; init; } = string.Empty;
public decimal Amount { get; init; }
public decimal Balance { get; init; }
public TransactionType Type { get; init; }
public string Classify { get; init; } = string.Empty;
public string? UnconfirmedClassify { get; init; }
public TransactionType? UnconfirmedType { get; init; }
}
/// <summary>
/// 创建交易请求
/// </summary>
public record CreateTransactionRequest
{
public string OccurredAt { get; init; } = string.Empty;
public string? Reason { get; init; }
public decimal Amount { get; init; }
public TransactionType Type { get; init; }
public string? Classify { get; init; }
}
/// <summary>
/// 更新交易请求
/// </summary>
public record UpdateTransactionRequest
{
public long Id { get; init; }
public string? Reason { get; init; }
public decimal Amount { get; init; }
public decimal Balance { get; init; }
public TransactionType Type { get; init; }
public string? Classify { get; init; }
public string? OccurredAt { get; init; }
}
/// <summary>
/// 交易查询请求
/// </summary>
public record TransactionQueryRequest
{
public int PageIndex { get; init; } = 1;
public int PageSize { get; init; } = 20;
public string? SearchKeyword { get; init; }
public string? Classify { get; init; }
public int? Type { get; init; }
public int? Year { get; init; }
public int? Month { get; init; }
public DateTime? StartDate { get; init; }
public DateTime? EndDate { get; init; }
public string? Reason { get; init; }
public bool SortByAmount { get; init; }
}
/// <summary>
/// 分页结果
/// </summary>
public record PagedResult<T>
{
public T[] Data { get; init; } = [];
public int Total { get; init; }
}
/// <summary>
/// 批量更新分类项
/// </summary>
public record BatchUpdateClassifyItem
{
public long Id { get; init; }
public string? Classify { get; init; }
public TransactionType? Type { get; init; }
}
/// <summary>
/// 按摘要批量更新请求
/// </summary>
public record BatchUpdateByReasonRequest
{
public string Reason { get; init; } = string.Empty;
public TransactionType Type { get; init; }
public string Classify { get; init; } = string.Empty;
}
/// <summary>
/// 一句话录账解析请求
/// </summary>
public record ParseOneLineRequest
{
public string Text { get; init; } = string.Empty;
}
/// <summary>
/// 确认所有未确认记录请求
/// </summary>
public record ConfirmAllUnconfirmedRequest
{
public long[] Ids { get; init; } = [];
}

View File

@@ -0,0 +1,131 @@
using Application.Dto.Email;
using Service.EmailServices;
namespace Application;
/// <summary>
/// 邮件消息应用服务接口
/// </summary>
public interface IEmailMessageApplication
{
/// <summary>
/// 获取邮件列表(分页)
/// </summary>
Task<EmailPagedResult> GetListAsync(EmailQueryRequest request);
/// <summary>
/// 根据ID获取邮件详情
/// </summary>
Task<EmailMessageResponse> GetByIdAsync(long id);
/// <summary>
/// 删除邮件
/// </summary>
Task DeleteByIdAsync(long id);
/// <summary>
/// 重新分析邮件并刷新交易记录
/// </summary>
Task RefreshTransactionRecordsAsync(long id);
/// <summary>
/// 立即同步邮件
/// </summary>
Task SyncEmailsAsync();
}
/// <summary>
/// 邮件消息应用服务实现
/// </summary>
public class EmailMessageApplication(
IEmailMessageRepository emailRepository,
ITransactionRecordRepository transactionRepository,
IEmailHandleService emailHandleService,
IEmailSyncService emailSyncService,
ILogger<EmailMessageApplication> logger
) : IEmailMessageApplication
{
public async Task<EmailPagedResult> GetListAsync(EmailQueryRequest request)
{
var (list, lastTime, lastId) = await emailRepository.GetPagedListAsync(
request.LastReceivedDate,
request.LastId);
var total = await emailRepository.GetTotalCountAsync();
// 为每个邮件获取账单数量
var emailResponses = new List<EmailMessageResponse>();
foreach (var email in list)
{
var transactionCount = await transactionRepository.GetCountByEmailIdAsync(email.Id);
emailResponses.Add(MapToResponse(email, transactionCount));
}
return new EmailPagedResult
{
Data = emailResponses.ToArray(),
Total = (int)total,
LastId = lastId,
LastTime = lastTime
};
}
public async Task<EmailMessageResponse> GetByIdAsync(long id)
{
var email = await emailRepository.GetByIdAsync(id);
if (email == null)
{
throw new NotFoundException("邮件不存在");
}
// 获取账单数量
var transactionCount = await transactionRepository.GetCountByEmailIdAsync(id);
return MapToResponse(email, transactionCount);
}
public async Task DeleteByIdAsync(long id)
{
var success = await emailRepository.DeleteAsync(id);
if (!success)
{
throw new BusinessException("删除邮件失败,邮件不存在");
}
}
public async Task RefreshTransactionRecordsAsync(long id)
{
var email = await emailRepository.GetByIdAsync(id);
if (email == null)
{
throw new NotFoundException("邮件不存在");
}
var success = await emailHandleService.RefreshTransactionRecordsAsync(id);
if (!success)
{
throw new BusinessException("重新分析失败");
}
}
public async Task SyncEmailsAsync()
{
await emailSyncService.SyncEmailsAsync();
}
private static EmailMessageResponse MapToResponse(EmailMessage email, int transactionCount)
{
return new EmailMessageResponse
{
Id = email.Id,
Subject = email.Subject,
From = email.From,
Body = email.Body,
HtmlBody = email.HtmlBody,
ReceivedDate = email.ReceivedDate,
CreateTime = email.CreateTime,
UpdateTime = email.UpdateTime,
TransactionCount = transactionCount,
ToName = email.To.Split('<').FirstOrDefault()?.Trim() ?? "未知"
};
}
}

View File

@@ -0,0 +1,16 @@
namespace Application.Exceptions;
/// <summary>
/// 应用层异常基类
/// </summary>
public class ApplicationException : Exception
{
public ApplicationException(string message) : base(message)
{
}
public ApplicationException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,19 @@
namespace Application.Exceptions;
/// <summary>
/// 业务逻辑异常对应HTTP 500 Internal Server Error
/// </summary>
/// <remarks>
/// 用于业务操作失败、数据状态不一致等场景
/// </remarks>
public class BusinessException : ApplicationException
{
public BusinessException(string message) : base(message)
{
}
public BusinessException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,11 @@
namespace Application.Exceptions;
/// <summary>
/// 资源未找到异常对应HTTP 404 Not Found
/// </summary>
/// <remarks>
/// 用于查询的资源不存在等场景
/// </remarks>
public class NotFoundException(string message) : ApplicationException(message)
{
}

View File

@@ -0,0 +1,11 @@
namespace Application.Exceptions;
/// <summary>
/// 业务验证异常对应HTTP 400 Bad Request
/// </summary>
/// <remarks>
/// 用于参数验证失败、业务规则不满足等场景
/// </remarks>
public class ValidationException(string message) : ApplicationException(message)
{
}

View File

@@ -0,0 +1,36 @@
namespace Application.Extensions;
/// <summary>
/// Application层服务注册扩展
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 注册所有Application层服务
/// </summary>
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
// 获取Application层程序集
var assembly = typeof(ServiceCollectionExtensions).Assembly;
// 自动注册所有以"Application"结尾的类
// 匹配接口规则: IXxxApplication -> XxxApplication
var applicationTypes = assembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && t.Name.EndsWith("Application"))
.ToList();
foreach (var implementationType in applicationTypes)
{
// 查找对应的接口 IXxxApplication
var interfaceType = implementationType.GetInterfaces()
.FirstOrDefault(i => i.Name == $"I{implementationType.Name}");
if (interfaceType != null)
{
services.AddScoped(interfaceType, implementationType);
}
}
return services;
}
}

View File

@@ -0,0 +1,13 @@
// 全局引用 - Application层
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.DependencyInjection;
global using System.Text;
// 项目引用
global using Entity;
global using Repository;
global using Service;
// Application层内部引用
global using Application.Exceptions;
global using Application.Dto;

View File

@@ -0,0 +1,112 @@
namespace Application;
/// <summary>
/// 导入类型
/// </summary>
public enum ImportType
{
/// <summary>
/// 支付宝
/// </summary>
Alipay,
/// <summary>
/// 微信
/// </summary>
WeChat
}
/// <summary>
/// 导入应用服务接口
/// </summary>
public interface IImportApplication
{
/// <summary>
/// 导入支付宝账单
/// </summary>
Task<ImportResponse> ImportAlipayAsync(ImportRequest request);
/// <summary>
/// 导入微信账单
/// </summary>
Task<ImportResponse> ImportWeChatAsync(ImportRequest request);
}
/// <summary>
/// 导入应用服务实现
/// </summary>
public class ImportApplication(
IImportService importService,
ILogger<ImportApplication> logger
) : IImportApplication
{
private static readonly string[] AllowedExtensions = { ".csv", ".xlsx", ".xls" };
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
public async Task<ImportResponse> ImportAlipayAsync(ImportRequest request)
{
ValidateRequest(request);
var (ok, message) = await importService.ImportAlipayAsync(
(MemoryStream)request.FileStream,
request.FileExtension
);
if (!ok)
{
throw new BusinessException(message);
}
logger.LogInformation("支付宝账单导入成功: {Message}", message);
return new ImportResponse { Message = message };
}
public async Task<ImportResponse> ImportWeChatAsync(ImportRequest request)
{
ValidateRequest(request);
var (ok, message) = await importService.ImportWeChatAsync(
(MemoryStream)request.FileStream,
request.FileExtension
);
if (!ok)
{
throw new BusinessException(message);
}
logger.LogInformation("微信账单导入成功: {Message}", message);
return new ImportResponse { Message = message };
}
/// <summary>
/// 验证导入请求
/// </summary>
private static void ValidateRequest(ImportRequest request)
{
// 验证文件流
if (request.FileStream == null || request.FileStream.Length == 0)
{
throw new ValidationException("请选择要上传的文件");
}
// 验证文件扩展名
if (string.IsNullOrWhiteSpace(request.FileExtension))
{
throw new ValidationException("文件扩展名不能为空");
}
var extension = request.FileExtension.ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
{
throw new ValidationException("只支持 CSV 或 Excel 文件格式");
}
// 验证文件大小
if (request.FileSize > MaxFileSize)
{
throw new ValidationException("文件大小不能超过 10MB");
}
}
}

View File

@@ -0,0 +1,107 @@
using Quartz;
using Quartz.Impl.Matchers;
namespace Application;
/// <summary>
/// 任务状态
/// </summary>
public record JobStatus
{
public string Name { get; init; } = string.Empty;
public string JobDescription { get; init; } = string.Empty;
public string TriggerDescription { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string NextRunTime { get; init; } = string.Empty;
}
/// <summary>
/// 任务应用服务接口
/// </summary>
public interface IJobApplication
{
Task<List<JobStatus>> GetJobsAsync();
Task<bool> ExecuteAsync(string jobName);
Task<bool> PauseAsync(string jobName);
Task<bool> ResumeAsync(string jobName);
}
/// <summary>
/// 任务应用服务实现
/// </summary>
public class JobApplication(
ISchedulerFactory schedulerFactory,
ILogger<JobApplication> logger
) : IJobApplication
{
public async Task<List<JobStatus>> GetJobsAsync()
{
var scheduler = await schedulerFactory.GetScheduler();
var jobKeys = await scheduler.GetJobKeys(GroupMatcher<JobKey>.AnyGroup());
var jobStatuses = new List<JobStatus>();
foreach (var jobKey in jobKeys)
{
var jobDetail = await scheduler.GetJobDetail(jobKey);
var triggers = await scheduler.GetTriggersOfJob(jobKey);
var trigger = triggers.FirstOrDefault();
var status = "Unknown";
DateTime? nextFireTime = null;
if (trigger != null)
{
var triggerState = await scheduler.GetTriggerState(trigger.Key);
status = triggerState.ToString();
nextFireTime = trigger.GetNextFireTimeUtc()?.ToLocalTime().DateTime;
}
jobStatuses.Add(new JobStatus
{
Name = jobKey.Name,
JobDescription = jobDetail?.Description ?? jobKey.Name,
TriggerDescription = trigger?.Description ?? string.Empty,
Status = status,
NextRunTime = nextFireTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无"
});
}
return jobStatuses;
}
public async Task<bool> ExecuteAsync(string jobName)
{
if (string.IsNullOrWhiteSpace(jobName))
{
throw new ValidationException("任务名称不能为空");
}
var scheduler = await schedulerFactory.GetScheduler();
await scheduler.TriggerJob(new JobKey(jobName));
return true;
}
public async Task<bool> PauseAsync(string jobName)
{
if (string.IsNullOrWhiteSpace(jobName))
{
throw new ValidationException("任务名称不能为空");
}
var scheduler = await schedulerFactory.GetScheduler();
await scheduler.PauseJob(new JobKey(jobName));
return true;
}
public async Task<bool> ResumeAsync(string jobName)
{
if (string.IsNullOrWhiteSpace(jobName))
{
throw new ValidationException("任务名称不能为空");
}
var scheduler = await schedulerFactory.GetScheduler();
await scheduler.ResumeJob(new JobKey(jobName));
return true;
}
}

View File

@@ -0,0 +1,97 @@
using Application.Dto.Message;
using Service.Message;
namespace Application;
/// <summary>
/// 消息记录应用服务接口
/// </summary>
public interface IMessageRecordApplication
{
/// <summary>
/// 获取消息列表(分页)
/// </summary>
Task<MessagePagedResult> GetListAsync(int pageIndex = 1, int pageSize = 20);
/// <summary>
/// 获取未读消息数量
/// </summary>
Task<long> GetUnreadCountAsync();
/// <summary>
/// 标记为已读
/// </summary>
Task<bool> MarkAsReadAsync(long id);
/// <summary>
/// 全部标记为已读
/// </summary>
Task<bool> MarkAllAsReadAsync();
/// <summary>
/// 删除消息
/// </summary>
Task<bool> DeleteAsync(long id);
/// <summary>
/// 新增消息
/// </summary>
Task<bool> AddAsync(MessageRecord message);
}
/// <summary>
/// 消息记录应用服务实现
/// </summary>
public class MessageRecordApplication(
IMessageService messageService,
ILogger<MessageRecordApplication> logger
) : IMessageRecordApplication
{
public async Task<MessagePagedResult> GetListAsync(int pageIndex = 1, int pageSize = 20)
{
var (list, total) = await messageService.GetPagedListAsync(pageIndex, pageSize);
return new MessagePagedResult
{
Data = list.Select(MapToResponse).ToArray(),
Total = (int)total
};
}
public async Task<long> GetUnreadCountAsync()
{
return await messageService.GetUnreadCountAsync();
}
public async Task<bool> MarkAsReadAsync(long id)
{
return await messageService.MarkAsReadAsync(id);
}
public async Task<bool> MarkAllAsReadAsync()
{
return await messageService.MarkAllAsReadAsync();
}
public async Task<bool> DeleteAsync(long id)
{
return await messageService.DeleteAsync(id);
}
public async Task<bool> AddAsync(MessageRecord message)
{
return await messageService.AddAsync(message);
}
private static MessageRecordResponse MapToResponse(MessageRecord record)
{
return new MessageRecordResponse
{
Id = record.Id,
Title = record.Title,
Content = record.Content,
IsRead = record.IsRead,
CreateTime = record.CreateTime
};
}
}

View File

@@ -0,0 +1,37 @@
using Service.Message;
namespace Application;
/// <summary>
/// 通知应用服务接口
/// </summary>
public interface INotificationApplication
{
Task<string> GetVapidPublicKeyAsync();
Task SubscribeAsync(PushSubscription subscription);
Task SendNotificationAsync(string message);
}
/// <summary>
/// 通知应用服务实现
/// </summary>
public class NotificationApplication(
INotificationService notificationService,
ILogger<NotificationApplication> logger
) : INotificationApplication
{
public async Task<string> GetVapidPublicKeyAsync()
{
return await notificationService.GetVapidPublicKeyAsync();
}
public async Task SubscribeAsync(PushSubscription subscription)
{
await notificationService.SubscribeAsync(subscription);
}
public async Task SendNotificationAsync(string message)
{
await notificationService.SendNotificationAsync(message);
}
}

View File

@@ -0,0 +1,388 @@
using Application.Dto.Transaction;
using Service.AI;
namespace Application;
/// <summary>
/// 交易应用服务接口
/// </summary>
public interface ITransactionApplication
{
/// <summary>
/// 获取交易记录列表(分页)
/// </summary>
Task<PagedResult<TransactionResponse>> GetListAsync(TransactionQueryRequest request);
/// <summary>
/// 根据ID获取交易记录详情
/// </summary>
Task<TransactionResponse> GetByIdAsync(long id);
/// <summary>
/// 创建交易记录
/// </summary>
Task CreateAsync(CreateTransactionRequest request);
/// <summary>
/// 更新交易记录
/// </summary>
Task UpdateAsync(UpdateTransactionRequest request);
/// <summary>
/// 删除交易记录
/// </summary>
Task DeleteByIdAsync(long id);
/// <summary>
/// 根据邮件ID获取交易记录列表
/// </summary>
Task<List<TransactionResponse>> GetByEmailIdAsync(long emailId);
/// <summary>
/// 根据日期获取交易记录列表
/// </summary>
Task<List<TransactionResponse>> GetByDateAsync(DateTime date);
/// <summary>
/// 获取未确认的交易记录列表
/// </summary>
Task<List<TransactionResponse>> GetUnconfirmedListAsync();
/// <summary>
/// 获取未确认的交易记录数量
/// </summary>
Task<int> GetUnconfirmedCountAsync();
/// <summary>
/// 获取未分类的账单数量
/// </summary>
Task<int> GetUnclassifiedCountAsync();
/// <summary>
/// 获取未分类的账单列表
/// </summary>
Task<List<TransactionResponse>> GetUnclassifiedAsync(int pageSize);
/// <summary>
/// 确认所有未确认的记录
/// </summary>
Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
/// <summary>
/// 智能分类AI分类支持回调
/// </summary>
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> onChunk);
/// <summary>
/// 一句话录账解析
/// </summary>
Task<TransactionParseResult?> ParseOneLineAsync(string text);
/// <summary>
/// 账单分析AI分析支持回调
/// </summary>
Task AnalyzeBillAsync(string userInput, Action<string> onChunk);
/// <summary>
/// 批量更新分类
/// </summary>
Task<int> BatchUpdateClassifyAsync(List<BatchUpdateClassifyItem> items);
/// <summary>
/// 按摘要批量更新分类
/// </summary>
Task<int> BatchUpdateByReasonAsync(BatchUpdateByReasonRequest request);
}
/// <summary>
/// 交易应用服务实现
/// </summary>
public class TransactionApplication(
ITransactionRecordRepository transactionRepository,
ISmartHandleService smartHandleService,
ILogger<TransactionApplication> logger
) : ITransactionApplication
{
public async Task<PagedResult<TransactionResponse>> GetListAsync(TransactionQueryRequest request)
{
var classifies = string.IsNullOrWhiteSpace(request.Classify)
? null
: request.Classify.Split(',', StringSplitOptions.RemoveEmptyEntries);
TransactionType? transactionType = request.Type.HasValue ? (TransactionType)request.Type.Value : null;
var list = await transactionRepository.QueryAsync(
year: request.Year,
month: request.Month,
startDate: request.StartDate,
endDate: request.EndDate,
type: transactionType,
classifies: classifies,
searchKeyword: request.SearchKeyword,
reason: request.Reason,
pageIndex: request.PageIndex,
pageSize: request.PageSize,
sortByAmount: request.SortByAmount);
var total = await transactionRepository.CountAsync(
year: request.Year,
month: request.Month,
startDate: request.StartDate,
endDate: request.EndDate,
type: transactionType,
classifies: classifies,
searchKeyword: request.SearchKeyword,
reason: request.Reason);
return new PagedResult<TransactionResponse>
{
Data = list.Select(MapToResponse).ToArray(),
Total = (int)total
};
}
public async Task<TransactionResponse> GetByIdAsync(long id)
{
var transaction = await transactionRepository.GetByIdAsync(id);
if (transaction == null)
{
throw new NotFoundException("交易记录不存在");
}
return MapToResponse(transaction);
}
public async Task CreateAsync(CreateTransactionRequest request)
{
// 解析日期字符串
if (!DateTime.TryParse(request.OccurredAt, out var occurredAt))
{
throw new ValidationException("交易时间格式不正确");
}
var transaction = new TransactionRecord
{
OccurredAt = occurredAt,
Reason = request.Reason ?? string.Empty,
Amount = request.Amount,
Type = request.Type,
Classify = request.Classify ?? string.Empty,
ImportFrom = "手动录入",
ImportNo = Guid.NewGuid().ToString("N"),
Card = "手动",
EmailMessageId = 0
};
var result = await transactionRepository.AddAsync(transaction);
if (!result)
{
throw new BusinessException("创建交易记录失败");
}
}
public async Task UpdateAsync(UpdateTransactionRequest request)
{
var transaction = await transactionRepository.GetByIdAsync(request.Id);
if (transaction == null)
{
throw new NotFoundException("交易记录不存在");
}
// 更新可编辑字段
transaction.Reason = request.Reason ?? string.Empty;
transaction.Amount = request.Amount;
transaction.Balance = request.Balance;
transaction.Type = request.Type;
transaction.Classify = request.Classify ?? string.Empty;
// 更新交易时间
if (!string.IsNullOrEmpty(request.OccurredAt) && DateTime.TryParse(request.OccurredAt, out var occurredAt))
{
transaction.OccurredAt = occurredAt;
}
// 清除待确认状态
transaction.UnconfirmedClassify = null;
transaction.UnconfirmedType = null;
var success = await transactionRepository.UpdateAsync(transaction);
if (!success)
{
throw new BusinessException("更新交易记录失败");
}
}
public async Task DeleteByIdAsync(long id)
{
var success = await transactionRepository.DeleteAsync(id);
if (!success)
{
throw new BusinessException("删除交易记录失败,记录不存在");
}
}
public async Task<List<TransactionResponse>> GetByEmailIdAsync(long emailId)
{
var transactions = await transactionRepository.GetByEmailIdAsync(emailId);
return transactions.Select(MapToResponse).ToList();
}
public async Task<List<TransactionResponse>> GetByDateAsync(DateTime date)
{
// 获取当天的开始和结束时间
var startDate = date.Date;
var endDate = startDate.AddDays(1);
var records = await transactionRepository.QueryAsync(startDate: startDate, endDate: endDate);
return records.Select(MapToResponse).ToList();
}
public async Task<List<TransactionResponse>> GetUnconfirmedListAsync()
{
var records = await transactionRepository.GetUnconfirmedRecordsAsync();
return records.Select(MapToResponse).ToList();
}
public async Task<int> GetUnconfirmedCountAsync()
{
var records = await transactionRepository.GetUnconfirmedRecordsAsync();
return records.Count;
}
public async Task<int> GetUnclassifiedCountAsync()
{
return (int)await transactionRepository.CountAsync();
}
public async Task<List<TransactionResponse>> GetUnclassifiedAsync(int pageSize)
{
var records = await transactionRepository.GetUnclassifiedAsync(pageSize);
return records.Select(MapToResponse).ToList();
}
public async Task<int> ConfirmAllUnconfirmedAsync(long[] ids)
{
if (ids == null || ids.Length == 0)
{
throw new ValidationException("请提供要确认的交易ID列表");
}
return await transactionRepository.ConfirmAllUnconfirmedAsync(ids);
}
public async Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> onChunk)
{
// 验证
if (transactionIds == null || transactionIds.Length == 0)
{
throw new ValidationException("请提供要分类的账单ID");
}
// 调用Service进行智能分类
await smartHandleService.SmartClassifyAsync(transactionIds, onChunk);
}
public async Task<TransactionParseResult?> ParseOneLineAsync(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
throw new ValidationException("解析文本不能为空");
}
var result = await smartHandleService.ParseOneLineBillAsync(text);
if (result == null)
{
throw new BusinessException("AI解析失败");
}
return result;
}
public async Task AnalyzeBillAsync(string userInput, Action<string> onChunk)
{
if (string.IsNullOrWhiteSpace(userInput))
{
throw new ValidationException("请输入分析内容");
}
await smartHandleService.AnalyzeBillAsync(userInput, onChunk);
}
public async Task<int> BatchUpdateClassifyAsync(List<BatchUpdateClassifyItem> items)
{
if (items == null || items.Count == 0)
{
throw new ValidationException("请提供要更新的记录");
}
var successCount = 0;
foreach (var item in items)
{
var record = await transactionRepository.GetByIdAsync(item.Id);
if (record != null)
{
record.Classify = item.Classify ?? string.Empty;
// 如果提供了Type也更新Type
if (item.Type.HasValue)
{
record.Type = item.Type.Value;
}
// 清除待确认状态
if (!string.IsNullOrEmpty(record.Classify))
{
record.UnconfirmedClassify = null;
}
if (item.Type.HasValue && record.Type == item.Type.Value)
{
record.UnconfirmedType = null;
}
var success = await transactionRepository.UpdateAsync(record);
if (success)
{
successCount++;
}
}
}
return successCount;
}
public async Task<int> BatchUpdateByReasonAsync(BatchUpdateByReasonRequest request)
{
if (string.IsNullOrWhiteSpace(request.Reason))
{
throw new ValidationException("摘要不能为空");
}
if (string.IsNullOrWhiteSpace(request.Classify))
{
throw new ValidationException("分类不能为空");
}
return await transactionRepository.BatchUpdateByReasonAsync(
request.Reason,
request.Type,
request.Classify);
}
private static TransactionResponse MapToResponse(TransactionRecord record)
{
return new TransactionResponse
{
Id = record.Id,
OccurredAt = record.OccurredAt,
Reason = record.Reason,
Amount = record.Amount,
Balance = record.Balance,
Type = record.Type,
Classify = record.Classify,
UnconfirmedClassify = record.UnconfirmedClassify,
UnconfirmedType = record.UnconfirmedType
};
}
}

View File

@@ -0,0 +1,231 @@
using Application.Dto.Category;
using Service.AI;
using System.Text.Json;
namespace Application;
/// <summary>
/// 交易分类应用服务接口
/// </summary>
public interface ITransactionCategoryApplication
{
Task<List<CategoryResponse>> GetListAsync(TransactionType? type = null);
Task<CategoryResponse> GetByIdAsync(long id);
Task<long> CreateAsync(CreateCategoryRequest request);
Task UpdateAsync(UpdateCategoryRequest request);
Task DeleteAsync(long id);
Task<int> BatchCreateAsync(List<CreateCategoryRequest> requests);
Task<string> GenerateIconAsync(GenerateIconRequest request);
Task UpdateSelectedIconAsync(UpdateSelectedIconRequest request);
}
/// <summary>
/// 交易分类应用服务实现
/// </summary>
public class TransactionCategoryApplication(
ITransactionCategoryRepository categoryRepository,
ITransactionRecordRepository transactionRepository,
IBudgetRepository budgetRepository,
ISmartHandleService smartHandleService,
ILogger<TransactionCategoryApplication> logger
) : ITransactionCategoryApplication
{
public async Task<List<CategoryResponse>> GetListAsync(TransactionType? type = null)
{
List<TransactionCategory> categories;
if (type.HasValue)
{
categories = await categoryRepository.GetCategoriesByTypeAsync(type.Value);
}
else
{
categories = (await categoryRepository.GetAllAsync()).ToList();
}
return categories.Select(MapToResponse).ToList();
}
public async Task<CategoryResponse> GetByIdAsync(long id)
{
var category = await categoryRepository.GetByIdAsync(id);
if (category == null)
{
throw new NotFoundException("分类不存在");
}
return MapToResponse(category);
}
public async Task<long> CreateAsync(CreateCategoryRequest request)
{
// 检查同名分类
var existing = await categoryRepository.GetByNameAndTypeAsync(request.Name, request.Type);
if (existing != null)
{
throw new ValidationException("已存在相同名称的分类");
}
var category = new TransactionCategory
{
Name = request.Name,
Type = request.Type
};
var result = await categoryRepository.AddAsync(category);
if (!result)
{
throw new BusinessException("创建分类失败");
}
return category.Id;
}
public async Task UpdateAsync(UpdateCategoryRequest request)
{
var category = await categoryRepository.GetByIdAsync(request.Id);
if (category == null)
{
throw new NotFoundException("分类不存在");
}
// 如果修改了名称,检查同名
if (category.Name != request.Name)
{
var existing = await categoryRepository.GetByNameAndTypeAsync(request.Name, category.Type);
if (existing != null && existing.Id != request.Id)
{
throw new ValidationException("已存在相同名称的分类");
}
// 同步更新交易记录中的分类名称
await transactionRepository.UpdateCategoryNameAsync(category.Name, request.Name, category.Type);
await budgetRepository.UpdateBudgetCategoryNameAsync(category.Name, request.Name, category.Type);
}
category.Name = request.Name;
category.UpdateTime = DateTime.Now;
var success = await categoryRepository.UpdateAsync(category);
if (!success)
{
throw new BusinessException("更新分类失败");
}
}
public async Task DeleteAsync(long id)
{
// 检查是否被使用
var inUse = await categoryRepository.IsCategoryInUseAsync(id);
if (inUse)
{
throw new ValidationException("该分类已被使用,无法删除");
}
var success = await categoryRepository.DeleteAsync(id);
if (!success)
{
throw new BusinessException("删除分类失败,分类不存在");
}
}
public async Task<int> BatchCreateAsync(List<CreateCategoryRequest> requests)
{
var categories = requests.Select(r => new TransactionCategory
{
Name = r.Name,
Type = r.Type
}).ToList();
var result = await categoryRepository.AddRangeAsync(categories);
if (!result)
{
throw new BusinessException("批量创建分类失败");
}
return categories.Count;
}
public async Task<string> GenerateIconAsync(GenerateIconRequest request)
{
var category = await categoryRepository.GetByIdAsync(request.CategoryId);
if (category == null)
{
throw new NotFoundException("分类不存在");
}
// 使用 SmartHandleService 统一封装的图标生成方法
var svg = await smartHandleService.GenerateSingleCategoryIconAsync(category.Name, category.Type);
if (string.IsNullOrWhiteSpace(svg))
{
throw new BusinessException("AI生成图标失败");
}
// 解析现有图标数组
var icons = string.IsNullOrWhiteSpace(category.Icon)
? new List<string>()
: JsonSerializer.Deserialize<List<string>>(category.Icon) ?? new List<string>();
// 添加新图标
icons.Add(svg);
// 更新数据库
category.Icon = JsonSerializer.Serialize(icons);
category.UpdateTime = DateTime.Now;
var success = await categoryRepository.UpdateAsync(category);
if (!success)
{
throw new BusinessException("更新分类图标失败");
}
return svg;
}
public async Task UpdateSelectedIconAsync(UpdateSelectedIconRequest request)
{
var category = await categoryRepository.GetByIdAsync(request.CategoryId);
if (category == null)
{
throw new NotFoundException("分类不存在");
}
// 验证索引有效性
if (string.IsNullOrWhiteSpace(category.Icon))
{
throw new ValidationException("该分类没有可用图标");
}
var icons = JsonSerializer.Deserialize<List<string>>(category.Icon);
if (icons == null || request.SelectedIndex < 0 || request.SelectedIndex >= icons.Count)
{
throw new ValidationException("无效的图标索引");
}
// 将选中的图标移到数组第一位
var selectedIcon = icons[request.SelectedIndex];
icons.RemoveAt(request.SelectedIndex);
icons.Insert(0, selectedIcon);
category.Icon = JsonSerializer.Serialize(icons);
category.UpdateTime = DateTime.Now;
var success = await categoryRepository.UpdateAsync(category);
if (!success)
{
throw new BusinessException("更新图标失败");
}
}
private static CategoryResponse MapToResponse(TransactionCategory category)
{
return new CategoryResponse
{
Id = category.Id,
Name = category.Name,
Type = category.Type,
Icon = category.Icon,
CreateTime = category.CreateTime,
UpdateTime = category.UpdateTime
};
}
}

View File

@@ -0,0 +1,170 @@
using Application.Dto.Periodic;
using Service.Transaction;
namespace Application;
/// <summary>
/// 周期性账单应用服务接口
/// </summary>
public interface ITransactionPeriodicApplication
{
/// <summary>
/// 获取周期性账单列表(分页)
/// </summary>
Task<PeriodicPagedResult> GetListAsync(int pageIndex = 1, int pageSize = 20, string? searchKeyword = null);
/// <summary>
/// 根据ID获取周期性账单详情
/// </summary>
Task<PeriodicResponse> GetByIdAsync(long id);
/// <summary>
/// 创建周期性账单
/// </summary>
Task<PeriodicResponse> CreateAsync(CreatePeriodicRequest request);
/// <summary>
/// 更新周期性账单
/// </summary>
Task UpdateAsync(UpdatePeriodicRequest request);
/// <summary>
/// 删除周期性账单
/// </summary>
Task DeleteByIdAsync(long id);
/// <summary>
/// 启用/禁用周期性账单
/// </summary>
Task ToggleEnabledAsync(long id, bool enabled);
}
/// <summary>
/// 周期性账单应用服务实现
/// </summary>
public class TransactionPeriodicApplication(
ITransactionPeriodicRepository periodicRepository,
ITransactionPeriodicService periodicService,
ILogger<TransactionPeriodicApplication> logger
) : ITransactionPeriodicApplication
{
public async Task<PeriodicPagedResult> GetListAsync(int pageIndex = 1, int pageSize = 20, string? searchKeyword = null)
{
var list = await periodicRepository.GetPagedListAsync(pageIndex, pageSize, searchKeyword);
var total = await periodicRepository.GetTotalCountAsync(searchKeyword);
return new PeriodicPagedResult
{
Data = list.Select(MapToResponse).ToArray(),
Total = (int)total
};
}
public async Task<PeriodicResponse> GetByIdAsync(long id)
{
var periodic = await periodicRepository.GetByIdAsync(id);
if (periodic == null)
{
throw new NotFoundException("周期性账单不存在");
}
return MapToResponse(periodic);
}
public async Task<PeriodicResponse> CreateAsync(CreatePeriodicRequest request)
{
var periodic = new TransactionPeriodic
{
PeriodicType = request.PeriodicType,
PeriodicConfig = request.PeriodicConfig ?? string.Empty,
Amount = request.Amount,
Type = request.Type,
Classify = request.Classify ?? string.Empty,
Reason = request.Reason ?? string.Empty,
IsEnabled = true
};
// 计算下次执行时间
periodic.NextExecuteTime = periodicService.CalculateNextExecuteTime(periodic, DateTime.Now);
var success = await periodicRepository.AddAsync(periodic);
if (!success)
{
throw new BusinessException("创建周期性账单失败");
}
return MapToResponse(periodic);
}
public async Task UpdateAsync(UpdatePeriodicRequest request)
{
var periodic = await periodicRepository.GetByIdAsync(request.Id);
if (periodic == null)
{
throw new NotFoundException("周期性账单不存在");
}
periodic.PeriodicType = request.PeriodicType;
periodic.PeriodicConfig = request.PeriodicConfig ?? string.Empty;
periodic.Amount = request.Amount;
periodic.Type = request.Type;
periodic.Classify = request.Classify ?? string.Empty;
periodic.Reason = request.Reason ?? string.Empty;
periodic.IsEnabled = request.IsEnabled;
periodic.UpdateTime = DateTime.Now;
// 重新计算下次执行时间
periodic.NextExecuteTime = periodicService.CalculateNextExecuteTime(periodic, DateTime.Now);
var success = await periodicRepository.UpdateAsync(periodic);
if (!success)
{
throw new BusinessException("更新周期性账单失败");
}
}
public async Task DeleteByIdAsync(long id)
{
var success = await periodicRepository.DeleteAsync(id);
if (!success)
{
throw new BusinessException("删除周期性账单失败");
}
}
public async Task ToggleEnabledAsync(long id, bool enabled)
{
var periodic = await periodicRepository.GetByIdAsync(id);
if (periodic == null)
{
throw new NotFoundException("周期性账单不存在");
}
periodic.IsEnabled = enabled;
periodic.UpdateTime = DateTime.Now;
var success = await periodicRepository.UpdateAsync(periodic);
if (!success)
{
throw new BusinessException("操作失败");
}
}
private static PeriodicResponse MapToResponse(TransactionPeriodic periodic)
{
return new PeriodicResponse
{
Id = periodic.Id,
PeriodicType = periodic.PeriodicType,
PeriodicConfig = periodic.PeriodicConfig,
Amount = periodic.Amount,
Type = periodic.Type,
Classify = periodic.Classify,
Reason = periodic.Reason,
IsEnabled = periodic.IsEnabled,
NextExecuteTime = periodic.NextExecuteTime,
CreateTime = periodic.CreateTime,
UpdateTime = periodic.UpdateTime
};
}
}

View File

@@ -0,0 +1,203 @@
using Application.Dto.Statistics;
using Service.Transaction;
namespace Application;
/// <summary>
/// 交易统计应用服务接口
/// </summary>
public interface ITransactionStatisticsApplication
{
// === 新统一接口(推荐使用) ===
/// <summary>
/// 按日期范围获取每日统计(新统一接口)
/// </summary>
Task<List<DailyStatisticsDto>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
/// <summary>
/// 按日期范围获取汇总统计(新统一接口)
/// </summary>
Task<MonthlyStatistics> GetSummaryByRangeAsync(DateTime startDate, DateTime endDate);
/// <summary>
/// 按日期范围获取分类统计(新统一接口)
/// </summary>
Task<List<CategoryStatistics>> GetCategoryStatisticsByRangeAsync(DateTime startDate, DateTime endDate, TransactionType type);
/// <summary>
/// 获取趋势统计数据
/// </summary>
Task<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount);
// === 旧接口(保留用于向后兼容,建议迁移到新接口) ===
[Obsolete("请使用 GetDailyStatisticsByRangeAsync")]
Task<List<BalanceStatisticsDto>> GetBalanceStatisticsAsync(int year, int month);
[Obsolete("请使用 GetDailyStatisticsByRangeAsync")]
Task<List<DailyStatisticsDto>> GetDailyStatisticsAsync(int year, int month);
[Obsolete("请使用 GetDailyStatisticsByRangeAsync")]
Task<List<DailyStatisticsDto>> GetWeeklyStatisticsAsync(DateTime startDate, DateTime endDate);
[Obsolete("请使用 GetSummaryByRangeAsync")]
Task<MonthlyStatistics> GetRangeStatisticsAsync(DateTime startDate, DateTime endDate);
[Obsolete("请使用 GetSummaryByRangeAsync")]
Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month);
[Obsolete("请使用 GetCategoryStatisticsByRangeAsync")]
Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type);
[Obsolete("请使用 GetCategoryStatisticsByRangeAsync")]
Task<List<CategoryStatistics>> GetCategoryStatisticsByDateRangeAsync(string startDate, string endDate, TransactionType type);
Task<(List<ReasonGroupDto> list, int total)> GetReasonGroupsAsync(int pageIndex, int pageSize);
}
/// <summary>
/// 交易统计应用服务实现
/// </summary>
public class TransactionStatisticsApplication(
ITransactionStatisticsService statisticsService,
IConfigService configService,
ILogger<TransactionStatisticsApplication> logger
) : ITransactionStatisticsApplication
{
// === 新统一接口实现 ===
/// <summary>
/// 按日期范围获取每日统计(新统一接口)
/// </summary>
public async Task<List<DailyStatisticsDto>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null)
{
// 如果未指定 savingClassify从配置读取
savingClassify ??= await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await statisticsService.GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify);
return statistics.Select(s => new DailyStatisticsDto(
DateTime.Parse(s.Key).Day,
s.Value.count,
s.Value.expense,
s.Value.income,
s.Value.saving
)).ToList();
}
/// <summary>
/// 按日期范围获取汇总统计(新统一接口)
/// </summary>
public async Task<MonthlyStatistics> GetSummaryByRangeAsync(DateTime startDate, DateTime endDate)
{
return await statisticsService.GetSummaryByRangeAsync(startDate, endDate);
}
/// <summary>
/// 按日期范围获取分类统计(新统一接口)
/// </summary>
public async Task<List<CategoryStatistics>> GetCategoryStatisticsByRangeAsync(DateTime startDate, DateTime endDate, TransactionType type)
{
return await statisticsService.GetCategoryStatisticsByDateRangeAsync(startDate, endDate, type);
}
// === 旧接口实现(保留用于向后兼容) ===
public async Task<List<BalanceStatisticsDto>> GetBalanceStatisticsAsync(int year, int month)
{
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await statisticsService.GetDailyStatisticsAsync(year, month, savingClassify);
var sortedStats = statistics.OrderBy(s => DateTime.Parse(s.Key)).ToList();
var result = new List<BalanceStatisticsDto>();
decimal cumulativeBalance = 0;
foreach (var item in sortedStats)
{
var dailyBalance = item.Value.income - item.Value.expense;
cumulativeBalance += dailyBalance;
result.Add(new BalanceStatisticsDto(DateTime.Parse(item.Key).Day, cumulativeBalance));
}
return result;
}
public async Task<List<DailyStatisticsDto>> GetDailyStatisticsAsync(int year, int month)
{
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await statisticsService.GetDailyStatisticsAsync(year, month, savingClassify);
return statistics.Select(s => new DailyStatisticsDto(
DateTime.Parse(s.Key).Day, // 从完整日期字符串 "yyyy-MM-dd" 中提取 day
s.Value.count,
s.Value.expense,
s.Value.income,
s.Value.saving
)).ToList();
}
public async Task<List<DailyStatisticsDto>> GetWeeklyStatisticsAsync(DateTime startDate, DateTime endDate)
{
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await statisticsService.GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify);
return statistics.Select(s => new DailyStatisticsDto(
DateTime.Parse(s.Key).Day, // 从完整日期字符串 "yyyy-MM-dd" 中提取 day
s.Value.count,
s.Value.expense,
s.Value.income,
s.Value.saving
)).ToList();
}
public async Task<MonthlyStatistics> GetRangeStatisticsAsync(DateTime startDate, DateTime endDate)
{
var records = await statisticsService.GetDailyStatisticsByRangeAsync(startDate, endDate, null);
var totalExpense = records.Sum(r => r.Value.expense);
var totalIncome = records.Sum(r => r.Value.income);
var totalCount = records.Sum(r => r.Value.count);
var expenseCount = records.Count(r => r.Value.expense > 0);
var incomeCount = records.Count(r => r.Value.income > 0);
return new MonthlyStatistics
{
Year = startDate.Year,
Month = startDate.Month,
TotalExpense = totalExpense,
TotalIncome = totalIncome,
Balance = totalIncome - totalExpense,
ExpenseCount = expenseCount,
IncomeCount = incomeCount,
TotalCount = totalCount
};
}
public async Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month)
{
return await statisticsService.GetMonthlyStatisticsAsync(year, month);
}
public async Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type)
{
return await statisticsService.GetCategoryStatisticsAsync(year, month, type);
}
public async Task<List<CategoryStatistics>> GetCategoryStatisticsByDateRangeAsync(string startDate, string endDate, TransactionType type)
{
var start = DateTime.Parse(startDate);
var end = DateTime.Parse(endDate);
return await statisticsService.GetCategoryStatisticsByDateRangeAsync(start, end, type);
}
public async Task<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount)
{
return await statisticsService.GetTrendStatisticsAsync(startYear, startMonth, monthCount);
}
public async Task<(List<ReasonGroupDto> list, int total)> GetReasonGroupsAsync(int pageIndex, int pageSize)
{
return await statisticsService.GetReasonGroupsAsync(pageIndex, pageSize);
}
}

View File

@@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entity", "Entity\Entity.csp
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi.Test", "WebApi.Test\WebApi.Test.csproj", "{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "Application\Application.csproj", "{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -97,6 +99,18 @@ Global
{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
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Debug|x64.ActiveCfg = Debug|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Debug|x64.Build.0 = Debug|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Debug|x86.ActiveCfg = Debug|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Debug|x86.Build.0 = Debug|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Release|Any CPU.Build.0 = Release|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Release|x64.ActiveCfg = Release|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Release|x64.Build.0 = Release|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Release|x86.ActiveCfg = Release|Any CPU
{6C98B9A7-261D-4C43-81DF-46A96C47B5EE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

76
README.md Normal file
View File

@@ -0,0 +1,76 @@
# EmailBill 项目文档归档
本目录存放项目开发过程中产生的各类文档,包括技术总结、迁移指南、验证报告等。
## 文档分类
### 1. Application 层重构文档2026-02-10
| 文档 | 说明 | 状态 |
|------|------|------|
| `APPLICATION_LAYER_PROGRESS.md` | Application 层重构完整进度报告 | ✅ 已完成 |
| `PHASE3_MIGRATION_GUIDE.md` | Phase 3 Controller 迁移详细指南 | ✅ 已完成 |
| `HANDOVER_SUMMARY.md` | Agent 交接总结报告 | ✅ 已完成 |
| `START_PHASE3.md` | Phase 3 快速启动指南 | ✅ 已完成 |
| `QUICK_START_GUIDE.md` | 快速恢复指南 | ✅ 已完成 |
**说明**: 这些文档记录了 Application 层重构的全过程,包括 12 个模块的实现、112 个测试的编写,以及 Controller 迁移的详细步骤。重构已于 2026-02-10 完成。
### 2. Repository 层重构文档2026-01-27
| 文档 | 说明 | 状态 |
|------|------|------|
| `REFACTORING_SUMMARY.md` | TransactionRecordRepository 重构总结 | ✅ 已完成 |
| `TransactionRecordRepository.md` | TransactionRecordRepository 查询语句文档 | 📚 参考文档 |
**说明**: 记录了 Repository 层的简化重构,将聚合逻辑从 Repository 移到 Service 层,提高了代码的可测试性和可维护性。
### 3. 功能验证报告
| 文档 | 说明 | 状态 |
|------|------|------|
| `CALENDARV2_VERIFICATION_REPORT.md` | CalendarV2 页面功能验证报告 | ✅ 已验证 |
| `VERSION_SWITCH_SUMMARY.md` | 版本切换功能实现总结 | ✅ 已完成 |
| `VERSION_SWITCH_TEST.md` | 版本切换功能测试文档 | ✅ 已完成 |
**说明**: 记录了新功能的验证和测试结果。
## 文档使用说明
### 查看历史文档
如果需要了解某个已完成功能的实现细节,可以参考对应的文档:
- **Application 层重构**: 查看 `PHASE3_MIGRATION_GUIDE.md` 了解详细的迁移步骤
- **Repository 层查询**: 查看 `TransactionRecordRepository.md` 了解所有查询语句
- **功能验证**: 查看对应的验证报告文档
### 当前项目状态
- **Application 层重构**: ✅ 100% 完成
- **Repository 层简化**: ✅ 100% 完成
- **CalendarV2 功能**: ✅ 已上线
- **版本切换功能**: ✅ 已上线
## 文档归档原则
1. **保留价值**: 具有技术参考价值的文档保留
2. **过时删除**: 完全过时、无参考价值的文档删除
3. **定期清理**: 每季度审查一次文档,清理不再需要的内容
## 根目录保留文档
- `AGENTS.md` - 项目知识库(经常访问,保留在根目录)
- `.github/csharpe.prompt.md` - C# 编码规范(技术规范,保留)
## 文档更新日志
| 日期 | 操作 | 说明 |
|------|------|------|
| 2026-02-10 | 创建归档 | 将 Phase 3 相关文档归档到 .doc 目录 |
| 2026-02-10 | 整理文档 | 移除过时文档,整理文档结构 |
---
**维护者**: AI Assistant
**最后更新**: 2026-02-10

View File

@@ -9,6 +9,29 @@ public interface ISmartHandleService
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
Task<TransactionParseResult?> ParseOneLineBillAsync(string text);
/// <summary>
/// 从邮件正文中使用AI提取交易记录AI兜底方案
/// </summary>
Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailByAiAsync(string emailBody);
/// <summary>
/// 为分类生成多个SVG图标定时任务使用
/// </summary>
Task<List<string>?> GenerateCategoryIconsAsync(string categoryName, TransactionType categoryType, int iconCount = 5);
/// <summary>
/// 为分类生成单个SVG图标手动触发使用
/// </summary>
Task<string?> GenerateSingleCategoryIconAsync(string categoryName, TransactionType categoryType);
/// <summary>
/// 生成预算执行报告HTML格式
/// </summary>
/// <param name="promptWithData">完整的Prompt包含数据和格式要求</param>
/// <param name="year">年份</param>
/// <param name="month">月份</param>
Task<string?> GenerateBudgetReportAsync(string promptWithData, int year, int month);
}
public class SmartHandleService(
@@ -538,6 +561,365 @@ public class SmartHandleService(
return categoryInfo.ToString();
}
/// <summary>
/// 从邮件正文中使用AI提取交易记录AI兜底方案
/// </summary>
public async Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailByAiAsync(string emailBody)
{
var systemPrompt = $"""
你是一个信息抽取助手。
仅输出严格的JSON数组不要包含任何多余文本。
每个交易记录包含字段: card(字符串), reason(字符串), amount(数字), balance(数字), type(字符串,值为'收入'或'支出'), occurredAt(字符串yyyy-MM-dd HH:mm:ss格式日期时间)。
如果缺失,请推断或置空。
[重要] 当前时间为{DateTime.Now:yyyy-MM-dd HH:mm:ss},请根据当前时间推断交易发生的时间。
""";
var userPrompt = $"""
从下面这封银行账单相关邮件正文中提取所有交易记录返回JSON数组格式
每个元素包含: card, reason, amount, balance, type(收入或支出), occurredAt(非必要)。
正文如下:\n\n{emailBody}
""";
var contentText = await openAiService.ChatAsync(systemPrompt, userPrompt);
if (string.IsNullOrWhiteSpace(contentText))
{
logger.LogWarning("AI未返回任何内容无法解析邮件");
return null;
}
logger.LogDebug("AI返回的内容: {Content}", contentText);
// 清理可能的 markdown 代码块标记
contentText = contentText.Trim();
if (contentText.StartsWith("```"))
{
// 移除开头的 ```json 或 ```
var firstNewLine = contentText.IndexOf('\n');
if (firstNewLine > 0)
{
contentText = contentText.Substring(firstNewLine + 1);
}
// 移除结尾的 ```
if (contentText.EndsWith("```"))
{
contentText = contentText.Substring(0, contentText.Length - 3);
}
contentText = contentText.Trim();
}
// contentText 期望是 JSON 数组
using var jsonDoc = JsonDocument.Parse(contentText);
var arrayElement = jsonDoc.RootElement;
// 如果返回的是单个对象而不是数组,尝试兼容处理
if (arrayElement.ValueKind == JsonValueKind.Object)
{
logger.LogWarning("AI返回的内容是单个对象而非数组尝试兼容处理");
var result = ParseEmailSingleRecord(arrayElement);
return result != null ? [result.Value] : null;
}
if (arrayElement.ValueKind != JsonValueKind.Array)
{
logger.LogWarning("AI返回的内容不是JSON数组无法解析邮件");
return null;
}
var results = new List<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)>();
foreach (var obj in arrayElement.EnumerateArray())
{
var record = ParseEmailSingleRecord(obj);
if (record != null)
{
logger.LogInformation("解析到一条交易记录: {@Record}", record.Value);
results.Add(record.Value);
}
}
logger.LogInformation("使用AI成功解析邮件内容提取到 {Count} 条交易记录", results.Count);
return results.Count > 0 ? results.ToArray() : null;
}
/// <summary>
/// 为分类生成多个SVG图标定时任务使用
/// </summary>
public async Task<List<string>?> GenerateCategoryIconsAsync(string categoryName, TransactionType categoryType, int iconCount = 5)
{
logger.LogInformation("正在为分类 {CategoryName} 生成 {IconCount} 个图标", categoryName, iconCount);
var typeText = categoryType == TransactionType.Expense ? "支出" : "收入";
var systemPrompt = """
SVG
5 SVG
1. 24x24viewBox="0 0 24 24"
2. 使
- 使 <linearGradient> <radialGradient>
- 使
-
3. 5
- 1使
- 2线
- 33D使
- 4
- 5线
4.
-
-
-
5.
6. JSON 5 SVG
SVG gradient
""";
var userPrompt = $"""
分类名称:{categoryName}
分类类型:{typeText}
请为这个分类生成 {iconCount} 个精美的、风格各异的彩色 SVG 图标。
确保每个图标都有独特的视觉特征,不会与其他图标混淆。
返回格式(纯 JSON 数组,无其他内容):
["<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>"]
""";
var response = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 60 * 10);
if (string.IsNullOrWhiteSpace(response))
{
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", categoryName);
return null;
}
// 验证返回的是有效的 JSON 数组
try
{
var icons = JsonSerializer.Deserialize<List<string>>(response);
if (icons == null || icons.Count != iconCount)
{
logger.LogWarning("AI 返回的图标数量不正确(期望{IconCount}个),分类: {CategoryName}", iconCount, categoryName);
return null;
}
logger.LogInformation("成功为分类 {CategoryName} 生成了 {IconCount} 个图标", categoryName, iconCount);
return icons;
}
catch (JsonException ex)
{
logger.LogError(ex, "解析 AI 返回的图标数据失败,分类: {CategoryName},响应内容: {Response}",
categoryName, response);
return null;
}
}
/// <summary>
/// 为分类生成单个SVG图标手动触发使用
/// </summary>
public async Task<string?> GenerateSingleCategoryIconAsync(string categoryName, TransactionType categoryType)
{
logger.LogInformation("正在为分类 {CategoryName} 生成单个图标", categoryName);
var typeText = categoryType == TransactionType.Expense ? "支出" : "收入";
var systemPrompt = """
SVG图标设计师SVG图标
1. 24x24viewBox="0 0 24 24"
2. 使
3.
4. SVG代码
""";
var userPrompt = $"""
请为「{categoryName}」{typeText}分类生成一个精美的SVG图标。
直接返回SVG代码无需解释。
""";
var svgContent = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 30);
if (string.IsNullOrWhiteSpace(svgContent))
{
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", categoryName);
return null;
}
// 提取SVG标签
var svgMatch = System.Text.RegularExpressions.Regex.Match(
svgContent,
@"<svg[^>]*>.*?</svg>",
System.Text.RegularExpressions.RegexOptions.Singleline);
if (!svgMatch.Success)
{
logger.LogWarning("生成的内容不包含有效的SVG标签分类: {CategoryName}", categoryName);
return null;
}
var svg = svgMatch.Value;
logger.LogInformation("成功为分类 {CategoryName} 生成单个图标", categoryName);
return svg;
}
/// <summary>
/// 生成预算执行报告HTML格式
/// </summary>
public async Task<string?> GenerateBudgetReportAsync(string promptWithData, int year, int month)
{
logger.LogInformation("正在生成预算执行报告: {Year}年{Month}月", year, month);
// 直接使用传入的完整prompt包含数据和格式要求
var htmlReport = await openAiService.ChatAsync(promptWithData);
if (string.IsNullOrWhiteSpace(htmlReport))
{
logger.LogWarning("AI 未返回有效的报告内容");
return null;
}
logger.LogInformation("成功生成预算执行报告: {Year}年{Month}月", year, month);
return htmlReport;
}
private (string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)? ParseEmailSingleRecord(JsonElement obj)
{
var card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty;
var reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty;
var typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty;
var occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty;
var amount = 0m;
if (obj.TryGetProperty("amount", out var pAmount))
{
if (pAmount.ValueKind == JsonValueKind.Number && pAmount.TryGetDecimal(out var d)) amount = d;
else if (pAmount.ValueKind == JsonValueKind.String && decimal.TryParse(pAmount.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) amount = ds;
}
var balance = 0m;
if (obj.TryGetProperty("balance", out var pBalance))
{
if (pBalance.ValueKind == JsonValueKind.Number && pBalance.TryGetDecimal(out var d2)) balance = d2;
else if (pBalance.ValueKind == JsonValueKind.String && decimal.TryParse(pBalance.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds2)) balance = ds2;
}
if (string.IsNullOrWhiteSpace(card) || string.IsNullOrWhiteSpace(reason))
{
return null;
}
var occurredAt = (DateTime?)null;
if (DateTime.TryParse(occurredAtStr, out var occurredAtValue))
{
occurredAt = occurredAtValue;
}
var type = DetermineTransactionType(typeStr, reason, amount);
return (card, reason, amount, balance, type, occurredAt);
}
/// <summary>
/// 判断交易类型
/// </summary>
private TransactionType DetermineTransactionType(string typeStr, string reason, decimal amount)
{
// 优先使用明确的类型字符串
if (!string.IsNullOrWhiteSpace(typeStr))
{
if (typeStr.Contains("收入") || typeStr.Contains("income") || typeStr.Equals("收", StringComparison.OrdinalIgnoreCase))
return TransactionType.Income;
if (typeStr.Contains("支出") || typeStr.Contains("expense") || typeStr.Equals("支", StringComparison.OrdinalIgnoreCase))
return TransactionType.Expense;
}
// 根据交易原因中的关键词判断
var lowerReason = reason.ToLower();
// 收入关键词
string[] incomeKeywords =
[
"工资", "奖金", "退款",
"返现", "收入", "转入",
"存入", "利息", "分红",
"入账", "收款",
// 常见扩展
"实发工资", "薪资", "薪水", "薪酬",
"提成", "佣金", "劳务费",
"报销入账", "报销款", "补贴", "补助",
"退款成功", "退回", "退货退款",
"返现入账", "返利", "返佣",
"到账", "已到账", "入账成功",
"收款成功", "收到款项", "到账金额",
"资金转入", "资金收入",
"转账收入", "转账入账", "他行来账",
"工资代发", "代发工资", "单位打款",
"利息收入", "收益", "收益发放", "理财收益",
"分红收入", "股息", "红利",
// 平台常用词
"红包", "红包收入", "红包入账",
"奖励金", "活动奖励", "补贴金",
"现金奖励", "推广奖励", "返现奖励",
// 存取类
"现金存入", "柜台存入", "ATM存入",
"他人转入", "他人汇入"
];
if (incomeKeywords.Any(k => lowerReason.Contains(k)))
return TransactionType.Income;
// 支出关键词
string[] expenseKeywords =
[
"消费", "支付", "购买",
"转出", "取款", "支出",
"扣款", "缴费", "付款",
"刷卡",
// 常见扩展
"支出金额", "支出人民币", "已支出",
"已消费", "消费支出", "消费人民币",
"已支付", "成功支付", "支付成功", "交易支付",
"已扣款", "扣款成功", "扣费", "扣费成功",
"转账", "转账支出", "向外转账", "已转出",
"提现", "现金支出", "现金取款",
"扣除", "扣除金额", "记账支出",
// 账单/通知类用语
"本期应还", "本期应还金额", "本期账单金额",
"本期应还人民币", "最低还款额",
"本期欠款", "欠款金额",
// 线上平台常见用语
"订单支付", "订单扣款", "订单消费",
"交易支出", "交易扣款", "交易成功支出",
"话费充值", "流量充值", "水费", "电费", "燃气费",
"物业费", "服务费", "手续费", "年费", "会费",
"利息支出", "还款支出", "代扣", "代缴",
// 信用卡/花呗等场景
"信用卡还款", "花呗还款", "白条还款",
"分期还款", "账单还款", "自动还款"
];
if (expenseKeywords.Any(k => lowerReason.Contains(k)))
return TransactionType.Expense;
// 根据金额正负判断(如果金额为负数,可能是支出)
if (amount < 0)
return TransactionType.Expense;
if (amount > 0)
return TransactionType.Income;
// 默认为支出
return TransactionType.Expense;
}
}
/// <summary>

View File

@@ -34,7 +34,7 @@ public class BudgetService(
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionRecordRepository transactionRecordRepository,
ITransactionStatisticsService transactionStatisticsService,
IOpenAiService openAiService,
ISmartHandleService smartHandleService,
IMessageService messageService,
ILogger<BudgetService> logger,
IBudgetSavingsService budgetSavingsService,
@@ -343,7 +343,8 @@ public class BudgetService(
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
""";
var htmlReport = await openAiService.ChatAsync(dataPrompt);
// 使用 SmartHandleService 统一封装的报告生成方法
var htmlReport = await smartHandleService.GenerateBudgetReportAsync(dataPrompt, year, month);
if (!string.IsNullOrEmpty(htmlReport))
{
await messageService.AddAsync(

View File

@@ -1,11 +1,11 @@
using Service.AI;
using Service.AI;
namespace Service.EmailServices.EmailParse;
public class EmailParseForm95555(
ILogger<EmailParseForm95555> logger,
IOpenAiService openAiService
) : EmailParseServicesBase(logger, openAiService)
ISmartHandleService smartHandleService
) : EmailParseServicesBase(logger, smartHandleService)
{
public override bool CanParse(string from, string subject, string body)
{

View File

@@ -1,4 +1,4 @@
using HtmlAgilityPack;
using HtmlAgilityPack;
using Service.AI;
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
@@ -8,8 +8,8 @@ namespace Service.EmailServices.EmailParse;
[UsedImplicitly]
public partial class EmailParseFormCcsvc(
ILogger<EmailParseFormCcsvc> logger,
IOpenAiService openAiService
) : EmailParseServicesBase(logger, openAiService)
ISmartHandleService smartHandleService
) : EmailParseServicesBase(logger, smartHandleService)
{
[GeneratedRegex("<.*?>")]
private static partial Regex HtmlRegex();

View File

@@ -1,4 +1,4 @@
using Service.AI;
using Service.AI;
namespace Service.EmailServices.EmailParse;
@@ -21,7 +21,7 @@ public interface IEmailParseServices
public abstract class EmailParseServicesBase(
ILogger<EmailParseServicesBase> logger,
IOpenAiService openAiService
ISmartHandleService smartHandleService
) : IEmailParseServices
{
public abstract bool CanParse(string from, string subject, string body);
@@ -44,8 +44,8 @@ public abstract class EmailParseServicesBase(
}
logger.LogInformation("规则解析邮件内容失败尝试使用AI进行解析");
// AI兜底
result = await ParseByAiAsync(emailContent) ?? [];
// AI兜底 - 使用 SmartHandleService 统一封装
result = await smartHandleService.ParseEmailByAiAsync(emailContent) ?? [];
if (result.Length == 0)
{
@@ -64,128 +64,8 @@ public abstract class EmailParseServicesBase(
DateTime? occurredAt
)[]> ParseEmailContentAsync(string emailContent);
private async Task<(
string card,
string reason,
decimal amount,
decimal balance,
TransactionType type,
DateTime? occurredAt
)[]?> ParseByAiAsync(string body)
{
var systemPrompt = $"""
你是一个信息抽取助手。
仅输出严格的JSON数组不要包含任何多余文本。
每个交易记录包含字段: card(字符串), reason(字符串), amount(数字), balance(数字), type(字符串,值为'收入'或'支出'), occurredAt(字符串yyyy-MM-dd HH:mm:ss格式日期时间)。
如果缺失,请推断或置空。
[重要] 当前时间为{DateTime.Now:yyyy-MM-dd HH:mm:ss},请根据当前时间推断交易发生的时间。
""";
var userPrompt = $"""
从下面这封银行账单相关邮件正文中提取所有交易记录返回JSON数组格式
每个元素包含: card, reason, amount, balance, type(收入或支出), occurredAt(非必要)。
正文如下:\n\n{body}
""";
var contentText = await openAiService.ChatAsync(systemPrompt, userPrompt);
if (string.IsNullOrWhiteSpace(contentText))
{
logger.LogWarning("AI未返回任何内容无法解析邮件");
return null;
}
logger.LogDebug("AI返回的内容: {Content}", contentText);
// 清理可能的 markdown 代码块标记
contentText = contentText.Trim();
if (contentText.StartsWith("```"))
{
// 移除开头的 ```json 或 ```
var firstNewLine = contentText.IndexOf('\n');
if (firstNewLine > 0)
{
contentText = contentText.Substring(firstNewLine + 1);
}
// 移除结尾的 ```
if (contentText.EndsWith("```"))
{
contentText = contentText.Substring(0, contentText.Length - 3);
}
contentText = contentText.Trim();
}
// contentText 期望是 JSON 数组
using var jsonDoc = JsonDocument.Parse(contentText);
var arrayElement = jsonDoc.RootElement;
// 如果返回的是单个对象而不是数组,尝试兼容处理
if (arrayElement.ValueKind == JsonValueKind.Object)
{
logger.LogWarning("AI返回的内容是单个对象而非数组尝试兼容处理");
var result = ParseSingleRecord(arrayElement);
return result != null ? [result.Value] : null;
}
if (arrayElement.ValueKind != JsonValueKind.Array)
{
logger.LogWarning("AI返回的内容不是JSON数组无法解析邮件");
return null;
}
var results = new List<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)>();
foreach (var obj in arrayElement.EnumerateArray())
{
var record = ParseSingleRecord(obj);
if (record != null)
{
logger.LogInformation("解析到一条交易记录: {@Record}", record.Value);
results.Add(record.Value);
}
}
logger.LogInformation("使用AI成功解析邮件内容提取到 {Count} 条交易记录", results.Count);
return results.Count > 0 ? results.ToArray() : null;
}
private (string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)? ParseSingleRecord(JsonElement obj)
{
var card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty;
var reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty;
var typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty;
var occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty;
var amount = 0m;
if (obj.TryGetProperty("amount", out var pAmount))
{
if (pAmount.ValueKind == JsonValueKind.Number && pAmount.TryGetDecimal(out var d)) amount = d;
else if (pAmount.ValueKind == JsonValueKind.String && decimal.TryParse(pAmount.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) amount = ds;
}
var balance = 0m;
if (obj.TryGetProperty("balance", out var pBalance))
{
if (pBalance.ValueKind == JsonValueKind.Number && pBalance.TryGetDecimal(out var d2)) balance = d2;
else if (pBalance.ValueKind == JsonValueKind.String && decimal.TryParse(pBalance.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds2)) balance = ds2;
}
if (string.IsNullOrWhiteSpace(card) || string.IsNullOrWhiteSpace(reason))
{
return null;
}
var occurredAt = (DateTime?)null;
if (DateTime.TryParse(occurredAtStr, out var occurredAtValue))
{
occurredAt = occurredAtValue;
}
var type = DetermineTransactionType(typeStr, reason, amount);
return (card, reason, amount, balance, type, occurredAt);
}
/// <summary>
/// 判断交易类型
/// 判断交易类型(供子类使用的通用方法)
/// </summary>
protected TransactionType DetermineTransactionType(string typeStr, string reason, decimal amount)
{

View File

@@ -1,4 +1,4 @@
using Quartz;
using Quartz;
using Service.AI;
namespace Service.Jobs;
@@ -9,7 +9,7 @@ namespace Service.Jobs;
/// </summary>
public class CategoryIconGenerationJob(
ITransactionCategoryRepository categoryRepository,
IOpenAiService openAiService,
ISmartHandleService smartHandleService,
ILogger<CategoryIconGenerationJob> logger) : IJob
{
private static readonly SemaphoreSlim _semaphore = new(1, 1);
@@ -77,74 +77,21 @@ public class CategoryIconGenerationJob(
logger.LogInformation("正在为分类 {CategoryName}(ID:{CategoryId}) 生成图标",
category.Name, category.Id);
var typeText = category.Type == TransactionType.Expense ? "支出" : "收入";
// 使用 SmartHandleService 统一封装的图标生成方法
var icons = await smartHandleService.GenerateCategoryIconsAsync(category.Name, category.Type, iconCount: 5);
var systemPrompt = """
SVG
5 SVG
1. 24x24viewBox="0 0 24 24"
2. 使
- 使 <linearGradient> <radialGradient>
- 使
-
3. 5
- 1使
- 2线
- 33D使
- 4
- 5线
4.
-
-
-
5.
6. JSON 5 SVG
SVG gradient
""";
var userPrompt = $"""
分类名称:{category.Name}
分类类型:{typeText}
请为这个分类生成 5 个精美的、风格各异的彩色 SVG 图标。
确保每个图标都有独特的视觉特征,不会与其他图标混淆。
返回格式(纯 JSON 数组,无其他内容):
["<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>"]
""";
var response = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 60 * 10);
if (string.IsNullOrWhiteSpace(response))
if (icons == null || icons.Count == 0)
{
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", category.Name);
logger.LogWarning("分类 {CategoryName}(ID:{CategoryId}) 生成图标失败",
category.Name, category.Id);
return;
}
// 验证返回的是有效的 JSON 数组
try
{
var icons = JsonSerializer.Deserialize<List<string>>(response);
if (icons == null || icons.Count != 5)
{
logger.LogWarning("AI 返回的图标数量不正确期望5个分类: {CategoryName}", category.Name);
return;
}
// 保存图标到数据库
category.Icon = JsonSerializer.Serialize(icons);
await categoryRepository.UpdateAsync(category);
// 保存图标到数据库
category.Icon = response;
await categoryRepository.UpdateAsync(category);
logger.LogInformation("成功为分类 {CategoryName}(ID:{CategoryId}) 生成并保存了 5 个图标",
category.Name, category.Id);
}
catch (JsonException ex)
{
logger.LogError(ex, "解析 AI 返回的图标数据失败,分类: {CategoryName},响应内容: {Response}",
category.Name, response);
}
logger.LogInformation("成功为分类 {CategoryName}(ID:{CategoryId}) 生成并保存了 {IconCount} 个图标",
category.Name, category.Id, icons.Count);
}
}

View File

@@ -8,8 +8,18 @@ public interface ITransactionStatisticsService
Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month);
/// <summary>
/// 按日期范围获取汇总统计数据(新统一接口)
/// </summary>
Task<MonthlyStatistics> GetSummaryByRangeAsync(DateTime startDate, DateTime endDate);
Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type);
/// <summary>
/// 按日期范围获取分类统计数据
/// </summary>
Task<List<CategoryStatistics>> GetCategoryStatisticsByDateRangeAsync(DateTime startDate, DateTime endDate, TransactionType type);
Task<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount);
Task<(List<ReasonGroupDto> list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20);
@@ -114,6 +124,44 @@ public class TransactionStatisticsService(
return statistics;
}
/// <summary>
/// 按日期范围获取汇总统计数据(新统一接口)
/// </summary>
public async Task<MonthlyStatistics> GetSummaryByRangeAsync(DateTime startDate, DateTime endDate)
{
var records = await transactionRepository.QueryAsync(
startDate: startDate,
endDate: endDate,
pageSize: int.MaxValue);
var statistics = new MonthlyStatistics
{
Year = startDate.Year,
Month = startDate.Month
};
foreach (var record in records)
{
var amount = Math.Abs(record.Amount);
if (record.Type == TransactionType.Expense)
{
statistics.TotalExpense += amount;
statistics.ExpenseCount++;
}
else if (record.Type == TransactionType.Income)
{
statistics.TotalIncome += amount;
statistics.IncomeCount++;
}
}
statistics.Balance = statistics.TotalIncome - statistics.TotalExpense;
statistics.TotalCount = records.Count;
return statistics;
}
public async Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type)
{
var records = await transactionRepository.QueryAsync(
@@ -145,6 +193,40 @@ public class TransactionStatisticsService(
return categoryGroups;
}
/// <summary>
/// 按日期范围获取分类统计数据
/// </summary>
public async Task<List<CategoryStatistics>> GetCategoryStatisticsByDateRangeAsync(DateTime startDate, DateTime endDate, TransactionType type)
{
var records = await transactionRepository.QueryAsync(
startDate: startDate,
endDate: endDate,
type: type,
pageSize: int.MaxValue);
var categoryGroups = records
.GroupBy(t => t.Classify)
.Select(g => new CategoryStatistics
{
Classify = g.Key,
Amount = g.Sum(t => Math.Abs(t.Amount)),
Count = g.Count()
})
.OrderByDescending(c => c.Amount)
.ToList();
var total = categoryGroups.Sum(c => c.Amount);
if (total > 0)
{
foreach (var category in categoryGroups)
{
category.Percent = Math.Round((category.Amount / total) * 100, 1);
}
}
return categoryGroups;
}
public async Task<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount)
{
var trends = new List<TrendStatistics>();

View File

@@ -94,6 +94,7 @@ const cachedViews = ref([
'CalendarV2', // 日历V2页面
'CalendarView', // 日历V1页面
'StatisticsView', // 统计页面
'StatisticsV2View', // 统计V2页面
'BalanceView', // 账单页面
'BudgetView' // 预算页面
])
@@ -231,6 +232,7 @@ const handleAddTransactionSuccess = () => {
/* 使用准确的视口高度 CSS 变量 */
height: var(--vh, 100vh);
width: 100%;
background-color: var(--van-background);
}
.app-root {
@@ -240,6 +242,7 @@ const handleAddTransactionSuccess = () => {
padding-top: max(0px, calc(env(safe-area-inset-top, 0px) * 0.75));
box-sizing: border-box;
overflow: hidden;
background-color: var(--van-background);
}
/* TabBar 固定在底部 */

View File

@@ -63,32 +63,38 @@ request.interceptors.response.use(
const { status, data } = error.response
let message = '请求失败'
switch (status) {
case 400:
message = data?.message || '请求参数错误'
break
case 401: {
message = '未授权,请重新登录'
// 清除登录状态并跳转到登录页
const authStore = useAuthStore()
authStore.logout()
router.push({
name: 'login',
query: { redirect: router.currentRoute.value.fullPath }
})
break
// 优先从后端返回的 BaseResponse 中提取 message
if (data && data.message) {
message = data.message
} else {
// 如果后端没有返回 message使用默认提示
switch (status) {
case 400:
message = '请求参数错误'
break
case 401: {
message = '未授权,请重新登录'
// 清除登录状态并跳转到登录页
const authStore = useAuthStore()
authStore.logout()
router.push({
name: 'login',
query: { redirect: router.currentRoute.value.fullPath }
})
break
}
case 403:
message = '拒绝访问'
break
case 404:
message = '请求的资源不存在'
break
case 500:
message = '服务器内部错误'
break
default:
message = `请求失败 (${status})`
}
case 403:
message = '拒绝访问'
break
case 404:
message = '请求的资源不存在'
break
case 500:
message = '服务器内部错误'
break
default:
message = data?.message || `请求失败 (${status})`
}
showToast(message)

View File

@@ -2,14 +2,38 @@ import request from './request'
/**
* 统计相关 API
* 注:统计接口定义在 TransactionRecordController 中
* 注:统计接口定义在 TransactionStatisticsController 中
*/
// ===== 新统一接口(推荐使用) =====
/**
* 获取月度统计数据
* 按日期范围获取每日统计(新统一接口)
* @param {Object} params - 查询参数
* @param {number} params.year - 年份
* @param {number} params.month - 月份
* @param {string} params.startDate - 开始日期(包含)格式: YYYY-MM-DD
* @param {string} params.endDate - 结束日期(不包含)格式: YYYY-MM-DD
* @param {string} [params.savingClassify] - 储蓄分类(可选,不传则使用系统配置)
* @returns {Promise<{success: boolean, data: Array}>}
* @returns {Array} data - 每日统计列表
* @returns {number} data[].day - 日期(天)
* @returns {number} data[].count - 交易笔数
* @returns {number} data[].expense - 支出金额
* @returns {number} data[].income - 收入金额
* @returns {number} data[].saving - 储蓄金额
*/
export const getDailyStatisticsByRange = (params) => {
return request({
url: '/TransactionStatistics/GetDailyStatisticsByRange',
method: 'get',
params
})
}
/**
* 按日期范围获取汇总统计(新统一接口)
* @param {Object} params - 查询参数
* @param {string} params.startDate - 开始日期(包含)格式: YYYY-MM-DD
* @param {string} params.endDate - 结束日期(不包含)格式: YYYY-MM-DD
* @returns {Promise<{success: boolean, data: Object}>}
* @returns {Object} data.totalExpense - 总支出
* @returns {Object} data.totalIncome - 总收入
@@ -18,19 +42,19 @@ import request from './request'
* @returns {Object} data.incomeCount - 收入笔数
* @returns {Object} data.totalCount - 总笔数
*/
export const getMonthlyStatistics = (params) => {
export const getSummaryByRange = (params) => {
return request({
url: '/TransactionRecord/GetMonthlyStatistics',
url: '/TransactionStatistics/GetSummaryByRange',
method: 'get',
params
})
}
/**
* 获取分类统计数据
* 按日期范围获取分类统计(新统一接口)
* @param {Object} params - 查询参数
* @param {number} params.year - 年份
* @param {number} params.month - 月份
* @param {string} params.startDate - 开始日期(包含)格式: YYYY-MM-DD
* @param {string} params.endDate - 结束日期(不包含)格式: YYYY-MM-DD
* @param {number} params.type - 交易类型 (0:支出, 1:收入, 2:不计入收支)
* @returns {Promise<{success: boolean, data: Array}>}
* @returns {Array} data - 分类统计列表
@@ -39,9 +63,9 @@ export const getMonthlyStatistics = (params) => {
* @returns {number} data[].percent - 百分比
* @returns {number} data[].count - 交易笔数
*/
export const getCategoryStatistics = (params) => {
export const getCategoryStatisticsByRange = (params) => {
return request({
url: '/TransactionRecord/GetCategoryStatistics',
url: '/TransactionStatistics/GetCategoryStatisticsByRange',
method: 'get',
params
})
@@ -62,7 +86,59 @@ export const getCategoryStatistics = (params) => {
*/
export const getTrendStatistics = (params) => {
return request({
url: '/TransactionRecord/GetTrendStatistics',
url: '/TransactionStatistics/GetTrendStatistics',
method: 'get',
params
})
}
// ===== 旧接口(保留用于向后兼容,建议迁移到新接口) =====
/**
* 获取月度统计数据
* @deprecated 请使用 getSummaryByRange
* @param {Object} params - 查询参数
* @param {number} params.year - 年份
* @param {number} params.month - 月份
* @returns {Promise<{success: boolean, data: Object}>}
*/
export const getMonthlyStatistics = (params) => {
return request({
url: '/TransactionStatistics/GetMonthlyStatistics',
method: 'get',
params
})
}
/**
* 获取分类统计数据
* @deprecated 请使用 getCategoryStatisticsByRange
* @param {Object} params - 查询参数
* @param {number} params.year - 年份
* @param {number} params.month - 月份
* @param {number} params.type - 交易类型 (0:支出, 1:收入, 2:不计入收支)
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getCategoryStatistics = (params) => {
return request({
url: '/TransactionStatistics/GetCategoryStatistics',
method: 'get',
params
})
}
/**
* 按日期范围获取分类统计数据
* @deprecated 请使用 getCategoryStatisticsByRangeDateTime 参数版本)
* @param {Object} params - 查询参数
* @param {string} params.startDate - 开始日期 (格式: YYYY-MM-DD)
* @param {string} params.endDate - 结束日期 (格式: YYYY-MM-DD)
* @param {number} params.type - 交易类型 (0:支出, 1:收入, 2:不计入收支)
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getCategoryStatisticsByDateRange = (params) => {
return request({
url: '/TransactionStatistics/GetCategoryStatisticsByDateRange',
method: 'get',
params
})
@@ -70,18 +146,15 @@ export const getTrendStatistics = (params) => {
/**
* 获取指定月份每天的消费统计
* @deprecated 请使用 getDailyStatisticsByRange
* @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[].count - 交易笔数
* @returns {number} data[].amount - 交易金额
*/
export const getDailyStatistics = (params) => {
return request({
url: '/TransactionRecord/GetDailyStatistics',
url: '/TransactionStatistics/GetDailyStatistics',
method: 'get',
params
})
@@ -89,17 +162,47 @@ export const getDailyStatistics = (params) => {
/**
* 获取累积余额统计数据(用于余额卡片)
* @deprecated 请使用 getDailyStatisticsByRange 并在前端计算累积余额
* @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',
url: '/TransactionStatistics/GetBalanceStatistics',
method: 'get',
params
})
}
/**
* 获取指定周范围的每天的消费统计
* @deprecated 请使用 getDailyStatisticsByRange
* @param {Object} params - 查询参数
* @param {string} params.startDate - 开始日期 (yyyy-MM-dd)
* @param {string} params.endDate - 结束日期 (yyyy-MM-dd)
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getWeeklyStatistics = (params) => {
return request({
url: '/TransactionStatistics/GetWeeklyStatistics',
method: 'get',
params
})
}
/**
* 获取指定日期范围的统计汇总数据
* @deprecated 请使用 getSummaryByRange
* @param {Object} params - 查询参数
* @param {string} params.startDate - 开始日期 (yyyy-MM-dd)
* @param {string} params.endDate - 结束日期 (yyyy-MM-dd)
* @returns {Promise<{success: boolean, data: Object}>}
*/
export const getRangeStatistics = (params) => {
return request({
url: '/TransactionStatistics/GetRangeStatistics',
method: 'get',
params
})

View File

@@ -189,7 +189,7 @@ export const batchUpdateClassify = (items) => {
*/
export const getReasonGroups = (pageIndex = 1, pageSize = 20) => {
return request({
url: '/TransactionRecord/GetReasonGroups',
url: '/TransactionStatistics/GetReasonGroups',
method: 'get',
params: { pageIndex, pageSize }
})

View File

@@ -87,6 +87,9 @@
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
/* 边框颜色 */
--border-color: #3f3f46;
/* 强调色 (深色主题调整) */
--accent-primary: #FF6B6B;
--accent-danger: #f87171;
@@ -134,7 +137,10 @@
background-color: var(--bg-tertiary);
}
/* 布局容器 */
/* 边框颜色 */
--border-color: #E5E7EB;
/* 布局容器 */
.container-fluid {
width: 100%;
max-width: 402px;

View File

@@ -0,0 +1,507 @@
<template>
<van-popup
v-model:show="visible"
position="bottom"
:style="{ height: '80%' }"
round
closeable
>
<div class="popup-wrapper">
<!-- 头部 -->
<div class="popup-header">
<h2 class="popup-title">
{{ title }}
</h2>
<div
v-if="total > 0"
class="popup-subtitle"
>
{{ total }} 笔交易
</div>
</div>
<!-- 交易列表 -->
<div class="transactions">
<!-- 加载状态 -->
<van-loading
v-if="loading && transactions.length === 0"
class="txn-loading"
size="24px"
vertical
>
加载中...
</van-loading>
<!-- 空状态 -->
<div
v-else-if="transactions.length === 0"
class="txn-empty"
>
<div class="empty-icon">
<van-icon
name="balance-list-o"
size="48"
/>
</div>
<div class="empty-text">
暂无交易记录
</div>
</div>
<!-- 交易列表 -->
<div
v-else
class="txn-list"
>
<div
v-for="txn in transactions"
:key="txn.id"
class="txn-card"
@click="onTransactionClick(txn)"
>
<div
class="txn-icon"
:style="{ backgroundColor: txn.iconBg }"
>
<van-icon
:name="txn.icon"
:color="txn.iconColor"
/>
</div>
<div class="txn-content">
<div class="txn-name">
{{ txn.reason }}
</div>
<div class="txn-footer">
<div class="txn-time">
{{ formatDateTime(txn.occurredAt) }}
</div>
<span
v-if="txn.classify"
class="txn-classify-tag"
:class="txn.type === 1 ? 'tag-income' : 'tag-expense'"
>
{{ txn.classify }}
</span>
</div>
</div>
<div class="txn-amount">
{{ formatAmount(txn.amount, txn.type) }}
</div>
</div>
<!-- 加载更多 -->
<div
v-if="!finished"
class="load-more"
>
<van-loading
v-if="loading"
size="20px"
>
加载中...
</van-loading>
<van-button
v-else
type="primary"
size="small"
@click="loadMore"
>
加载更多
</van-button>
</div>
<!-- 已加载全部 -->
<div
v-else
class="finished-text"
>
已加载全部
</div>
</div>
</div>
</div>
</van-popup>
<!-- 交易详情弹窗 -->
<TransactionDetailSheet
v-model:show="showDetail"
:transaction="currentTransaction"
@save="handleSave"
@delete="handleDelete"
/>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import { getTransactionList } from '@/api/transactionRecord'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
classify: {
type: String,
default: ''
},
type: {
type: Number,
default: 0
},
year: {
type: Number,
required: true
},
month: {
type: Number,
required: true
}
})
const emit = defineEmits(['update:modelValue', 'refresh'])
// 双向绑定
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 标题
const title = computed(() => {
const classifyText = props.classify || '未分类'
const typeText = props.type === 0 ? '支出' : props.type === 1 ? '收入' : '不计收支'
return `${classifyText} - ${typeText}`
})
// 数据状态
const transactions = ref([])
const loading = ref(false)
const finished = ref(false)
const pageIndex = ref(1)
const pageSize = 20
const total = ref(0)
// 详情弹窗
const showDetail = ref(false)
const currentTransaction = ref(null)
// 格式化日期时间
const formatDateTime = (dateTimeStr) => {
const date = new Date(dateTimeStr)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}`
}
// 格式化金额
const formatAmount = (amount, type) => {
const sign = type === 1 ? '+' : '-'
return `${sign}${amount.toFixed(2)}`
}
// 根据分类获取图标
const getIconByClassify = (classify) => {
const iconMap = {
'餐饮': 'food',
'购物': 'shopping',
'交通': 'logistics',
'娱乐': 'play-circle',
'医疗': 'medic',
'工资': 'gold-coin',
'红包': 'gift'
}
return iconMap[classify] || 'bill'
}
// 根据类型获取颜色
const getColorByType = (type) => {
return type === 1 ? '#22C55E' : '#FF6B6B'
}
// 加载数据
const loadData = async (isRefresh = false) => {
if (loading.value || finished.value) {
return
}
if (isRefresh) {
pageIndex.value = 1
transactions.value = []
finished.value = false
}
loading.value = true
try {
const params = {
pageIndex: pageIndex.value,
pageSize: pageSize,
type: props.type,
year: props.year,
month: props.month || 0,
sortByAmount: true
}
if (props.classify) {
params.classify = props.classify
}
const response = await getTransactionList(params)
if (response.success) {
const newList = response.data || []
// 转换数据格式,添加显示所需的字段
const formattedList = newList.map(txn => ({
...txn,
icon: getIconByClassify(txn.classify),
iconColor: getColorByType(txn.type),
iconBg: '#FFFFFF'
}))
transactions.value = [...transactions.value, ...formattedList]
total.value = response.total
if (newList.length === 0 || newList.length < pageSize) {
finished.value = true
} else {
pageIndex.value++
}
} else {
showToast(response.message || '加载账单失败')
finished.value = true
}
} catch (error) {
console.error('加载分类账单失败:', error)
showToast('加载账单失败')
finished.value = true
} finally {
loading.value = false
}
}
// 加载更多
const loadMore = () => {
loadData(false)
}
// 点击交易
const onTransactionClick = (txn) => {
currentTransaction.value = txn
showDetail.value = true
}
// 保存交易
const handleSave = () => {
showDetail.value = false
// 重新加载数据
loadData(true)
// 通知父组件刷新
emit('refresh')
}
// 删除交易
const handleDelete = (id) => {
showDetail.value = false
// 从列表中移除
transactions.value = transactions.value.filter(t => t.id !== id)
total.value--
// 通知父组件刷新
emit('refresh')
}
// 监听弹窗打开
watch(visible, (newValue) => {
if (newValue) {
loadData(true)
} else {
// 关闭时重置状态
transactions.value = []
pageIndex.value = 1
finished.value = false
total.value = 0
}
})
</script>
<style scoped>
@import '@/assets/theme.css';
.popup-wrapper {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--bg-primary);
}
.popup-header {
flex-shrink: 0;
padding: var(--spacing-2xl);
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.popup-title {
font-family: var(--font-display);
font-size: var(--font-xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
text-align: center;
letter-spacing: -0.02em;
}
.popup-subtitle {
margin-top: var(--spacing-sm);
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-secondary);
text-align: center;
}
.transactions {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: var(--spacing-lg);
}
.txn-loading {
padding: var(--spacing-3xl);
text-align: center;
}
.txn-list {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.txn-card {
display: flex;
align-items: center;
gap: 14px;
padding: var(--spacing-xl);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: opacity 0.2s;
margin-top: 10px;
}
.txn-card:active {
opacity: 0.7;
}
.txn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.txn-content {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
flex: 1;
min-width: 0;
}
.txn-name {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txn-footer {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.txn-time {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-tertiary);
}
.txn-classify-tag {
padding: 2px 8px;
font-size: var(--font-xs);
font-weight: var(--font-medium);
border-radius: var(--radius-full);
flex-shrink: 0;
}
.txn-classify-tag.tag-income {
background-color: rgba(34, 197, 94, 0.15);
color: var(--accent-success);
}
.txn-classify-tag.tag-expense {
background-color: rgba(59, 130, 246, 0.15);
color: #3B82F6;
}
.txn-amount {
font-size: var(--font-lg);
font-weight: var(--font-bold);
color: var(--text-primary);
flex-shrink: 0;
margin-left: var(--spacing-md);
}
.load-more {
display: flex;
justify-content: center;
padding: var(--spacing-xl) 0;
}
.finished-text {
text-align: center;
padding: var(--spacing-xl) 0;
font-size: var(--font-md);
color: var(--text-tertiary);
}
/* 空状态 */
.txn-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
padding: var(--spacing-4xl) var(--spacing-2xl);
gap: var(--spacing-md);
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
color: var(--text-tertiary);
margin-bottom: var(--spacing-sm);
}
.empty-text {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-secondary);
}
</style>

View File

@@ -0,0 +1,183 @@
<template>
<header class="calendar-header">
<!-- 左箭头 -->
<button
class="nav-btn"
aria-label="上一个周期"
@click="emit('prev')"
>
<van-icon name="arrow-left" />
</button>
<!-- 标题内容可点击跳转 -->
<div
class="header-content"
@click="emit('jump')"
>
<h1 class="header-title">
{{ formattedTitle }}
</h1>
</div>
<!-- 右箭头 -->
<button
class="nav-btn"
aria-label="下一个周期"
@click="emit('next')"
>
<van-icon name="arrow" />
</button>
<!-- 通知按钮 -->
<button
v-if="showNotification"
class="notif-btn"
aria-label="通知"
@click="emit('notification')"
>
<van-icon name="bell" />
</button>
</header>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
type: {
type: String,
required: true,
validator: (value) => ['week', 'month', 'year'].includes(value)
},
currentDate: {
type: Date,
required: true
},
showNotification: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['prev', 'next', 'jump', 'notification'])
/**
* 计算 ISO 8601 标准的周数
* @param date 目标日期
* @returns 周数 (1-53)
*/
const getISOWeek = (date) => {
const target = new Date(date.valueOf())
const dayNr = (date.getDay() + 6) % 7 // 周一为0周日为6
target.setDate(target.getDate() - dayNr + 3) // 本周四
const firstThursday = new Date(target.getFullYear(), 0, 4) // 该年第一个周四
const weekDiff = Math.floor((target.valueOf() - firstThursday.valueOf()) / 86400000)
return 1 + Math.floor(weekDiff / 7)
}
/**
* 计算 ISO 8601 标准的年份(用于周数)
* 注意:年末/年初的周可能属于相邻年份
*/
const getISOYear = (date) => {
const target = new Date(date.valueOf())
const dayNr = (date.getDay() + 6) % 7
target.setDate(target.getDate() - dayNr + 3) // 本周四
return target.getFullYear()
}
// 格式化标题
const formattedTitle = computed(() => {
const date = props.currentDate
const year = date.getFullYear()
const month = date.getMonth() + 1
switch (props.type) {
case 'week': {
const isoYear = getISOYear(date)
const weekNum = getISOWeek(date)
return `${isoYear}年第${weekNum}`
}
case 'month':
return `${year}${month}`
case 'year':
return `${year}`
default:
return ''
}
})
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
/* ========== 头部 ========== */
.calendar-header {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 8px 24px;
gap: 8px;
background: transparent !important;
position: relative;
z-index: 1;
}
.header-content {
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.header-title {
font-family: var(--font-primary);
font-size: var(--font-2xl);
font-weight: var(--font-medium);
color: var(--text-primary);
margin: 0;
transition: opacity 0.2s;
}
.header-content:active .header-title {
opacity: 0.6;
}
.notif-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background-color: var(--bg-button);
border: none;
cursor: pointer;
transition: opacity 0.2s;
margin-left: auto;
}
.notif-btn:active {
opacity: 0.7;
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 18px;
background-color: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.nav-btn:active {
background-color: var(--bg-tertiary);
}
</style>

View File

@@ -0,0 +1,313 @@
<template>
<div class="glass-nav-container">
<!-- 渐变淡化效果 -->
<div class="gradient-fade">
<!-- 药丸形导航栏 -->
<div class="nav-pill">
<div
v-for="(item, index) in navItems"
:key="item.name"
class="nav-item"
:class="{ 'nav-item-active': activeTab === item.name }"
@click="handleTabClick(item, index)"
>
<van-icon
:name="item.icon"
size="24"
:color="getIconColor(activeTab === item.name)"
/>
<span
class="nav-label"
:class="{ 'nav-label-active': activeTab === item.name }"
>
{{ item.label }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const props = defineProps({
modelValue: {
type: String,
default: 'statistics'
},
items: {
type: Array,
default () {
return [
{ name: 'calendar', label: '日历', icon: 'notes', path: '/calendar' },
{ name: 'statistics', label: '统计', icon: 'chart-trending-o', path: '/' },
{ name: 'balance', label: '账单', icon: 'balance-list', path: '/balance' },
{ name: 'budget', label: '预算', icon: 'bill-o', path: '/budget' },
{ name: 'setting', label: '设置', icon: 'setting', path: '/setting' }
]
}
}
})
const emit = defineEmits(['update:modelValue', 'tab-click'])
const router = useRouter()
const route = useRoute()
// 使用计算属性来获取导航项,优先使用传入的 props
const navItems = computed(() => props.items)
// 响应式的活动标签状态
const activeTab = ref(props.modelValue)
// 检测当前主题(暗色或亮色)
const isDarkMode = ref(false)
const updateTheme = () => {
isDarkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
// 根据主题和激活状态计算图标颜色
const getIconColor = (isActive) => {
if (isActive) {
// 激活状态:暗色模式用浅色,亮色模式用深色
return isDarkMode.value ? '#FAFAF9' : '#1A1A1A'
} else {
// 非激活状态:暗色模式用灰色,亮色模式用浅灰色
return isDarkMode.value ? '#6B6B6F' : '#9CA3AF'
}
}
// 根据当前路由路径匹配对应的导航项
const getActiveTabFromRoute = (currentPath) => {
// 规范化路径: 去掉 -v2 后缀以支持版本切换
const normalizedPath = currentPath.replace(/-v2$/, '')
const matchedItem = navItems.value.find(item => {
if (!item.path) {return false}
// 完全匹配
if (item.path === currentPath || item.path === normalizedPath) {
return true
}
// 首页特殊处理: '/' 应该匹配 '/' 和 '/statistics*'
if (item.path === '/' && (currentPath === '/' || normalizedPath === '/statistics')) {
return true
}
return false
})
return matchedItem?.name || props.modelValue
}
// 更新激活状态的通用方法
const updateActiveTab = (newTab) => {
if (newTab && newTab !== activeTab.value) {
activeTab.value = newTab
emit('update:modelValue', newTab)
}
}
// 监听外部 modelValue 的变化
watch(() => props.modelValue, (newValue) => {
updateActiveTab(newValue)
}, { immediate: true })
// 监听路由变化,自动同步底部导航高亮状态
watch(() => route.path, (newPath) => {
const matchedTab = getActiveTabFromRoute(newPath)
updateActiveTab(matchedTab)
}, { immediate: true })
const handleTabClick = (item, index) => {
activeTab.value = item.name
emit('update:modelValue', item.name)
emit('tab-click', item, index)
// 如果有路径定义,则进行路由跳转
if (item.path) {
router.push(item.path).catch(err => {
// 忽略相同路由导航错误
if (err.name !== 'NavigationDuplicated') {
console.warn('Navigation error:', err)
}
})
}
}
// 组件挂载时确保状态正确
onMounted(() => {
const matchedTab = getActiveTabFromRoute(route.path)
updateActiveTab(matchedTab)
// 初始化主题检测
updateTheme()
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateTheme)
// 组件卸载时清理监听器
const cleanup = () => {
mediaQuery.removeEventListener('change', updateTheme)
}
// Vue 3 中可以直接在 onMounted 中返回清理函数
return cleanup
})
</script>
<style scoped lang="scss">
.glass-nav-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 95px;
z-index: 1000;
pointer-events: none;
}
/* 亮色模式渐变(默认) */
.gradient-fade {
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(246, 247, 248, 0) 0%, rgba(246, 247, 248, 1) 50%);
padding: 12px 21px 21px 21px;
pointer-events: none;
display: flex;
align-items: flex-end;
}
/* 亮色模式导航栏(默认) - 增强透明和毛玻璃效果 */
.nav-pill {
width: 100%;
height: 62px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border: 1px solid rgba(229, 231, 235, 0.6);
border-radius: 31px;
padding: 4px;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08), 0 0 0 0.5px rgba(255, 255, 255, 0.5) inset;
pointer-events: auto;
}
.nav-item {
width: 56px;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 27px;
&:active {
transform: scale(0.95);
}
}
/* 亮色模式文字颜色(默认) */
.nav-label {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 10px;
font-weight: 500;
color: #9CA3AF;
transition: all 0.2s ease;
line-height: 1.2;
}
.nav-label-active {
font-weight: 600;
color: #1A1A1A;
}
/* 适配安全区域 */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.gradient-fade {
padding-bottom: calc(21px + env(safe-area-inset-bottom));
}
}
/* 响应式适配 */
@media (max-width: 375px) {
.gradient-fade {
padding-left: 16px;
padding-right: 16px;
}
.nav-item {
width: 52px;
}
}
/* 深色模式适配 - 增强透明和毛玻璃效果 */
@media (prefers-color-scheme: dark) {
.gradient-fade {
background: linear-gradient(180deg, rgba(11, 11, 14, 0) 0%, rgba(11, 11, 14, 1) 50%);
}
.nav-pill {
background: rgba(26, 26, 30, 0.75);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border-color: rgba(42, 42, 46, 0.6);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.25), 0 0 0 0.5px rgba(255, 255, 255, 0.1) inset;
}
.nav-label {
color: #6B6B6F;
}
.nav-label-active {
color: #FAFAF9;
}
}
/* iOS 样式优化 */
.nav-pill {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* 毛玻璃效果增强 - 根据浏览器支持调整透明度 */
@supports (backdrop-filter: blur(40px)) {
.nav-pill {
background: rgba(255, 255, 255, 0.6);
}
@media (prefers-color-scheme: dark) {
.nav-pill {
background: rgba(26, 26, 30, 0.65);
}
}
}
@supports not (backdrop-filter: blur(40px)) {
/* 浏览器不支持毛玻璃效果时,增加不透明度以确保可读性 */
.nav-pill {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
@media (prefers-color-scheme: dark) {
.nav-pill {
background: rgba(26, 26, 30, 0.95);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
}
</style>

View File

@@ -0,0 +1,345 @@
<template>
<div
class="modern-empty"
:class="[`theme-${theme}`, `size-${size}`]"
>
<div class="empty-content">
<!-- 图标容器 -->
<div
class="icon-container"
:class="{ 'with-animation': animation }"
>
<div class="icon-bg" />
<div class="icon-wrapper">
<!-- 自定义图标插槽 -->
<slot name="icon">
<svg
class="empty-icon"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<component :is="iconPath" />
</svg>
</slot>
</div>
</div>
<!-- 文字内容 -->
<div class="text-content">
<h3
v-if="title"
class="empty-title"
>
{{ title }}
</h3>
<p
v-if="description"
class="empty-description"
>
{{ description }}
</p>
</div>
<!-- 操作按钮 -->
<div
v-if="$slots.action || actionText"
class="empty-action"
>
<slot name="action">
<van-button
v-if="actionText"
type="primary"
round
size="small"
@click="$emit('action-click')"
>
{{ actionText }}
</van-button>
</slot>
</div>
</div>
</div>
</template>
<script setup>
import { computed, h } from 'vue'
const props = defineProps({
// 空状态类型search, data, inbox, calendar, finance, chart
type: {
type: String,
default: 'search'
},
// 主题色blue, green, orange, purple, gray
theme: {
type: String,
default: 'blue'
},
// 标题
title: {
type: String,
default: ''
},
// 描述文字
description: {
type: String,
default: '暂无数据'
},
// 是否显示动画
animation: {
type: Boolean,
default: true
},
// 操作按钮文字
actionText: {
type: String,
default: ''
},
// 尺寸small, medium, large
size: {
type: String,
default: 'medium'
}
})
defineEmits(['action-click'])
// 根据类型选择SVG图标路径
const iconPath = computed(() => {
const icons = {
search: () => h('g', [
h('circle', { cx: '26', cy: '26', r: '18', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' }),
h('path', { d: 'M40 40L54 54', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round' })
]),
data: () => h('g', [
h('path', { d: 'M8 48L22 32L36 40L56 16', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', fill: 'none' }),
h('circle', { cx: '8', cy: '48', r: '3', fill: 'currentColor' }),
h('circle', { cx: '22', cy: '32', r: '3', fill: 'currentColor' }),
h('circle', { cx: '36', cy: '40', r: '3', fill: 'currentColor' }),
h('circle', { cx: '56', cy: '16', r: '3', fill: 'currentColor' })
]),
inbox: () => h('g', [
h('path', { d: 'M8 16L32 4L56 16V52C56 54.2 54.2 56 52 56H12C9.8 56 8 54.2 8 52V16Z', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' }),
h('path', { d: 'M8 32H20L24 40H40L44 32H56', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' })
]),
calendar: () => h('g', [
h('rect', { x: '8', y: '12', width: '48', height: '44', rx: '4', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' }),
h('path', { d: 'M8 24H56', stroke: 'currentColor', 'stroke-width': '3' }),
h('path', { d: 'M20 8V16', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round' }),
h('path', { d: 'M44 8V16', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round' })
]),
finance: () => h('g', [
h('circle', { cx: '32', cy: '32', r: '24', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' }),
h('path', { d: 'M32 16V48', stroke: 'currentColor', 'stroke-width': '3' }),
h('path', { d: 'M24 22H36C38.2 22 40 23.8 40 26C40 28.2 38.2 30 36 30H28C25.8 30 24 31.8 24 34C24 36.2 25.8 38 28 38H40', stroke: 'currentColor', 'stroke-width': '3', fill: 'none' })
]),
chart: () => h('g', [
h('rect', { x: '12', y: '36', width: '8', height: '20', rx: '2', fill: 'currentColor' }),
h('rect', { x: '28', y: '24', width: '8', height: '32', rx: '2', fill: 'currentColor' }),
h('rect', { x: '44', y: '12', width: '8', height: '44', rx: '2', fill: 'currentColor' })
])
}
return icons[props.type] || icons.search
})
</script>
<style scoped lang="scss">
.modern-empty {
width: 100%;
padding: 40px 20px;
display: flex;
justify-content: center;
align-items: center;
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
max-width: 300px;
}
// 图标容器
.icon-container {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 24px;
&.with-animation {
.icon-bg {
animation: pulse 2s ease-in-out infinite;
}
.icon-wrapper {
animation: float 3s ease-in-out infinite;
}
}
.icon-bg {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
opacity: 0.1;
}
.icon-wrapper {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.empty-icon {
width: 56px;
height: 56px;
opacity: 0.6;
}
}
}
// 文字内容
.text-content {
text-align: center;
margin-bottom: 20px;
.empty-title {
font-size: 18px;
font-weight: 600;
color: var(--van-text-color);
margin: 0 0 8px;
line-height: 1.4;
}
.empty-description {
font-size: 14px;
color: var(--van-text-color-2);
margin: 0;
line-height: 1.6;
}
}
// 操作按钮
.empty-action {
margin-top: 4px;
}
// 主题色
&.theme-blue {
.icon-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.empty-icon {
color: #667eea;
}
}
&.theme-green {
.icon-bg {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
.empty-icon {
color: #11998e;
}
}
&.theme-orange {
.icon-bg {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.empty-icon {
color: #f5576c;
}
}
&.theme-purple {
.icon-bg {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.empty-icon {
color: #4facfe;
}
}
&.theme-gray {
.icon-bg {
background: linear-gradient(135deg, #bdc3c7 0%, #2c3e50 100%);
}
.empty-icon {
color: #95a5a6;
}
}
}
// 动画
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 0.1;
}
50% {
transform: scale(1.05);
opacity: 0.15;
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
// 尺寸变体
.modern-empty.size-small {
padding: 24px 16px;
.icon-container {
width: 80px;
height: 80px;
margin-bottom: 16px;
.empty-icon {
width: 40px;
height: 40px;
}
}
.text-content {
.empty-title {
font-size: 16px;
}
.empty-description {
font-size: 13px;
}
}
}
.modern-empty.size-large {
padding: 60px 20px;
.icon-container {
width: 160px;
height: 160px;
margin-bottom: 32px;
.empty-icon {
width: 72px;
height: 72px;
}
}
.text-content {
.empty-title {
font-size: 20px;
}
.empty-description {
font-size: 15px;
}
}
}
</style>

View File

@@ -1,4 +1,4 @@
<!-- eslint-disable vue/no-v-html -->
<!-- eslint-disable vue/no-v-html -->
<template>
<van-popup
v-model:show="visible"
@@ -111,7 +111,7 @@ const hasActions = computed(() => !!slots['header-actions'])
.popup-header-fixed {
flex-shrink: 0;
padding: 16px;
background-color: var(--van-background-2);
background: linear-gradient(180deg, var(--van-background) 0%, var(--van-background-2) 100%);
border-bottom: 1px solid var(--van-border-color);
position: sticky;
top: 0;
@@ -138,10 +138,11 @@ const hasActions = computed(() => !!slots['header-actions'])
.popup-title {
font-size: 16px;
font-weight: 500;
font-weight: 600;
margin: 0;
text-align: center;
color: var(--van-text-color);
letter-spacing: -0.02em;
/*超出长度*/
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -0,0 +1,90 @@
<template>
<div class="tabs-wrapper">
<div class="segmented-control">
<div
class="tab-item"
:class="{ active: activeTab === 'week' }"
@click="$emit('change', 'week')"
>
<span class="tab-text"></span>
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'month' }"
@click="$emit('change', 'month')"
>
<span class="tab-text"></span>
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'year' }"
@click="$emit('change', 'year')"
>
<span class="tab-text"></span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
activeTab: {
type: String,
required: true,
validator: (value) => ['week', 'month', 'year'].includes(value)
}
})
defineEmits(['change'])
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
.tabs-wrapper {
padding: var(--spacing-sm) var(--spacing-xl);
.segmented-control {
display: flex;
background: transparent;
border-radius: var(--radius-md);
padding: 0;
gap: var(--spacing-sm);
height: 40px;
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: rgba(128, 128, 128, 0.15);
&.active {
background: rgba(128, 128, 128, 0.3);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
.tab-text {
color: var(--text-primary);
font-weight: var(--font-bold);
}
}
&:not(.active):hover {
background: rgba(128, 128, 128, 0.2);
}
.tab-text {
font-family: var(--font-primary);
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-secondary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
}
}
}
</style>

View File

@@ -33,7 +33,10 @@
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
</div>
<!-- 编辑模式 -->
<div v-else class="amount-input-wrapper">
<div
v-else
class="amount-input-wrapper"
>
<span class="currency-symbol">¥</span>
<input
ref="amountInputRef"

View File

@@ -68,7 +68,13 @@ const router = createRouter({
{
path: '/',
name: 'statistics',
component: () => import('../views/StatisticsView.vue'),
component: () => import('../views/statisticsV1/Index.vue'),
meta: { requiresAuth: true }
},
{
path: '/statistics-v2',
name: 'statistics-v2',
component: () => import('../views/statisticsV2/Index.vue'),
meta: { requiresAuth: true }
},
{

44
Web/src/utils/format.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* 格式化金额
* @param {number} value 金额数值
* @returns {string} 格式化后的金额字符串
*/
export const formatMoney = (value) => {
if (!value && value !== 0) {
return '0'
}
return Number(value)
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
/**
* 格式化日期
* @param {Date|string} date 日期
* @param {string} format 格式化模板
* @returns {string} 格式化后的日期字符串
*/
export const formatDate = (date, format = 'YYYY-MM-DD') => {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
}
/**
* 格式化百分比
* @param {number} value 数值
* @param {number} decimals 小数位数
* @returns {string} 格式化后的百分比字符串
*/
export const formatPercent = (value, decimals = 1) => {
if (!value && value !== 0) {
return '0%'
}
return `${Number(value).toFixed(decimals)}%`
}

View File

@@ -55,6 +55,12 @@
ref="messageViewRef"
:is-component="true"
/>
<!-- 液态玻璃底部导航栏 -->
<GlassBottomNav
v-model="activeNavTab"
@tab-click="handleNavTabClick"
/>
</div>
</template>
@@ -64,8 +70,17 @@ import { useRoute } from 'vue-router'
import TransactionsRecord from './TransactionsRecord.vue'
import EmailRecord from './EmailRecord.vue'
import MessageView from './MessageView.vue'
import GlassBottomNav from '@/components/GlassBottomNav.vue'
const route = useRoute()
// 底部导航栏
const activeNavTab = ref('balance')
const handleNavTabClick = (item, index) => {
console.log('Tab clicked:', item.name, index)
// 导航逻辑已在组件内部处理
}
const tabActive = ref(route.query.tab || 'balance')
// 监听路由参数变化,用于从 tabbar 点击时切换 tab

View File

@@ -497,6 +497,12 @@
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 液态玻璃底部导航栏 -->
<GlassBottomNav
v-model="navActiveTab"
@tab-click="handleNavTabClick"
/>
</div>
</template>
@@ -518,6 +524,14 @@ import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue'
import BudgetChartAnalysis from '@/components/Budget/BudgetChartAnalysis.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import GlassBottomNav from '@/components/GlassBottomNav.vue'
// 底部导航栏
const navActiveTab = ref('budget')
const handleNavTabClick = (item, index) => {
console.log('Tab clicked:', item.name, index)
// 导航逻辑已在组件内部处理
}
const activeTab = ref(BudgetCategory.Expense)
const selectedDate = ref(new Date())

View File

@@ -44,6 +44,12 @@
:transaction="currentTransaction"
@save="onDetailSave"
/>
<!-- 液态玻璃底部导航栏 -->
<GlassBottomNav
v-model="activeTab"
@tab-click="handleTabClick"
/>
</div>
</template>
@@ -56,6 +62,14 @@ import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
import SmartClassifyButton from '@/components/SmartClassifyButton.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import GlassBottomNav from '@/components/GlassBottomNav.vue'
// 底部导航栏
const activeTab = ref('calendar')
const handleTabClick = (item, index) => {
console.log('Tab clicked:', item.name, index)
// 导航逻辑已在组件内部处理
}
const dailyStatistics = ref({})
const listVisible = ref(false)

View File

@@ -139,6 +139,12 @@
<!-- 底部安全距离 -->
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))" />
</div>
<!-- 液态玻璃底部导航栏 -->
<GlassBottomNav
v-model="activeTab"
@tab-click="handleTabClick"
/>
</div>
</template>
@@ -151,10 +157,19 @@ import { useAuthStore } from '@/stores/auth'
import { useVersionStore } from '@/stores/version'
import { getVapidPublicKey, subscribe, testNotification } from '@/api/notification'
import { updateServiceWorker } from '@/registerServiceWorker'
import GlassBottomNav from '@/components/GlassBottomNav.vue'
const router = useRouter()
const authStore = useAuthStore()
const versionStore = useVersionStore()
// 底部导航栏
const activeTab = ref('setting')
const handleTabClick = (item, index) => {
console.log('Tab clicked:', item.name, index)
// 导航逻辑已在组件内部处理
}
const fileInputRef = ref(null)
const currentType = ref('')
const notificationEnabled = ref(false)

View File

@@ -1,34 +1,14 @@
<template>
<div class="page-container-flex calendar-v2-wrapper">
<!-- 头部固定 -->
<header class="calendar-header">
<button
class="month-nav-btn"
aria-label="上一月"
@click="changeMonth(-1)"
>
<van-icon name="arrow-left" />
</button>
<div class="header-content">
<h1 class="header-title">
{{ currentMonth }}
</h1>
</div>
<button
class="month-nav-btn"
aria-label="下一月"
@click="changeMonth(1)"
>
<van-icon name="arrow" />
</button>
<button
class="notif-btn"
aria-label="通知"
@click="onNotificationClick"
>
<van-icon name="bell" />
</button>
</header>
<CalendarHeader
type="month"
:current-date="currentDate"
@prev="changeMonth(-1)"
@next="changeMonth(1)"
@jump="onDateJump"
@notification="onNotificationClick"
/>
<!-- 可滚动内容区域 -->
<div class="calendar-scroll-content">
@@ -65,16 +45,51 @@
<div class="bottom-spacer" />
</van-pull-refresh>
</div>
<!-- 交易详情弹窗 -->
<TransactionDetailSheet
v-model:show="showTransactionDetail"
:transaction="currentTransaction"
@save="handleTransactionSave"
@delete="handleTransactionDelete"
/>
<!-- 日期选择器弹窗 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
>
<van-date-picker
v-model="pickerDate"
title="选择年月"
:min-date="new Date(2020, 0, 1)"
:max-date="new Date()"
:columns-type="['year', 'month']"
@confirm="onDatePickerConfirm"
@cancel="onDatePickerCancel"
/>
</van-popup>
<!-- 液态玻璃底部导航栏 -->
<GlassBottomNav
v-model="activeTab"
@tab-click="handleTabClick"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import CalendarHeader from '@/components/DateSelectHeader.vue'
import CalendarModule from './modules/Calendar.vue'
import StatsModule from './modules/Stats.vue'
import TransactionListModule from './modules/TransactionList.vue'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import GlassBottomNav from '@/components/GlassBottomNav.vue'
import { getTransactionDetail } from '@/api/transactionRecord'
// 定义组件名称keep-alive 需要通过 name 识别)
defineOptions({
@@ -84,6 +99,13 @@ defineOptions({
// 路由
const router = useRouter()
// 底部导航栏
const activeTab = ref('calendar')
const handleTabClick = (item, index) => {
console.log('Tab clicked:', item.name, index)
// 导航逻辑已在组件内部处理
}
// 下拉刷新状态
const refreshing = ref(false)
@@ -95,12 +117,9 @@ const selectedDate = ref(new Date())
const slideDirection = ref('slide-left')
const calendarKey = ref(0)
// 当前月份格式化(中文)
const currentMonth = computed(() => {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth() + 1
return `${year}${month}`
})
// 日期选择器相关
const showDatePicker = ref(false)
const pickerDate = ref(new Date())
// 格式化日期为 key (yyyy-MM-dd)
const formatDateKey = (date) => {
@@ -135,12 +154,35 @@ const onDayClick = async (day) => {
selectedDate.value = clickedDate
}
// 点击交易卡片 - 跳转到详情页
const onTransactionClick = (txn) => {
router.push({
path: '/transaction-detail',
query: { id: txn.id }
})
// 交易详情弹窗相关
const showTransactionDetail = ref(false)
const currentTransaction = ref(null)
// 点击交易卡片 - 打开详情弹窗
const onTransactionClick = async (txn) => {
try {
// 获取完整的交易详情
const response = await getTransactionDetail(txn.id)
if (response.success && response.data) {
currentTransaction.value = response.data
showTransactionDetail.value = true
} else {
showToast('获取交易详情失败')
}
} catch (error) {
console.error('获取交易详情失败:', error)
showToast('获取交易详情失败')
}
}
// 保存交易后刷新列表
const handleTransactionSave = () => {
handleTransactionsChanged()
}
// 删除交易后刷新列表
const handleTransactionDelete = () => {
handleTransactionsChanged()
}
// 点击通知按钮
@@ -148,6 +190,38 @@ const onNotificationClick = () => {
router.push('/message')
}
// 点击日期标题,打开日期选择器
const onDateJump = () => {
pickerDate.value = new Date(currentDate.value)
showDatePicker.value = true
}
// 确认日期选择
const onDatePickerConfirm = ({ selectedValues }) => {
const [year, month] = selectedValues
const newDate = new Date(year, month - 1, 1)
// 检查是否超过当前月
const today = new Date()
if (newDate.getFullYear() > today.getFullYear() ||
(newDate.getFullYear() === today.getFullYear() && newDate.getMonth() > today.getMonth())) {
showToast('不能选择未来的月份')
showDatePicker.value = false
return
}
// 更新日期
currentDate.value = newDate
selectedDate.value = newDate
calendarKey.value += 1
showDatePicker.value = false
}
// 取消日期选择
const onDatePickerCancel = () => {
showDatePicker.value = false
}
// 点击 Smart 按钮 - 跳转到智能分类页面
const onSmartClick = () => {
router.push({
@@ -301,7 +375,6 @@ onBeforeUnmount(() => {
/* ========== 页面容器 ========== */
.calendar-v2-wrapper {
background-color: var(--bg-primary);
font-family: var(--font-primary);
color: var(--text-primary);
}
@@ -311,66 +384,7 @@ onBeforeUnmount(() => {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* ========== 头部 ========== */
.calendar-header {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 8px 24px;
gap: 8px;
background: transparent !important;
}
.header-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-title {
font-family: var(--font-primary);
font-size: var(--font-2xl);
font-weight: var(--font-medium);
color: var(--text-primary);
margin: 0;
}
.notif-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background-color: var(--bg-button);
border: none;
cursor: pointer;
transition: opacity 0.2s;
margin-left: auto;
}
.notif-btn:active {
opacity: 0.7;
}
.month-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 18px;
background-color: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.month-nav-btn:active {
background-color: var(--bg-tertiary);
background-color: var(--bg-primary);
}
/* 底部安全距离 */

View File

@@ -141,7 +141,8 @@ const fetchAllRelevantMonthsData = async (year, month) => {
const needNextMonth = totalCells > (startDayOfWeek + lastDay.getDate())
// 并行加载所有需要的月份数据
const promises = [fetchDailyStats(year, month)]
// JavaScript Date.month 是 0-11但后端 API 期望 1-12
const promises = [fetchDailyStats(year, month + 1)]
if (needPrevMonth) {
const prevMonth = month === 0 ? 11 : month - 1

View File

@@ -14,11 +14,19 @@
</div>
</template>
<template #right>
<van-icon
name="chat-o"
size="20"
@click="goToAnalysis"
/>
<div class="nav-right-buttons">
<van-icon
name="upgrade"
size="18"
style="margin-right: 12px;"
@click="goToStatisticsV2"
/>
<van-icon
name="chat-o"
size="20"
@click="goToAnalysis"
/>
</div>
</template>
</van-nav-bar>
@@ -310,6 +318,12 @@
:transaction="currentTransaction"
@save="onBillSave"
/>
<!-- 液态玻璃底部导航栏 -->
<GlassBottomNav
v-model="activeTab"
@tab-click="handleTabClick"
/>
</div>
</template>
@@ -325,10 +339,18 @@ import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
import SmartClassifyButton from '@/components/SmartClassifyButton.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import GlassBottomNav from '@/components/GlassBottomNav.vue'
import { getCssVar } from '@/utils/theme'
const router = useRouter()
//
const activeTab = ref('statistics')
const handleTabClick = (item, index) => {
console.log('Tab clicked:', item.name, index)
//
}
//
const loading = ref(true)
const firstLoading = ref(true)
@@ -1063,6 +1085,11 @@ const goToAnalysis = () => {
router.push('/bill-analysis')
}
// V2
const goToStatisticsV2 = () => {
router.push('/statistics-v2')
}
//
const goToCategoryBills = (classify, type) => {
selectedClassify.value = classify || '未分类' // TODO
@@ -1338,6 +1365,12 @@ watch(dateSelectionMode, (newMode) => {
color: var(--van-text-color);
}
/* 导航栏右侧按钮组 */
.nav-right-buttons {
display: flex;
align-items: center;
}
/* 余额卡片 */
.balance-amount {
text-align: center;

View File

@@ -0,0 +1,407 @@
<template>
<!-- 支出分类统计 -->
<div
class="common-card"
style="padding-bottom: 10px;"
>
<div class="card-header">
<h3 class="card-title">
支出分类
</h3>
<van-tag
type="primary"
size="medium"
>
{{ expenseCategoriesView.length }}
</van-tag>
</div>
<!-- 环形图区域 -->
<div
v-if="expenseCategoriesView.length > 0"
class="chart-container"
>
<div class="ring-chart">
<div
ref="pieChartRef"
style="width: 100%; height: 100%"
/>
</div>
</div>
<!-- 分类列表 -->
<div class="category-list">
<div
v-for="category in expenseCategoriesSimpView"
:key="category.classify"
class="category-item clickable"
@click="$emit('category-click', category.classify, 0)"
>
<div class="category-info">
<div
class="category-color"
:style="{ backgroundColor: category.color }"
/>
<div class="category-name-with-count">
<span class="category-name">{{ category.classify || '未分类' }}</span>
<span class="category-count">{{ category.count }}</span>
</div>
</div>
<div class="category-stats">
<div class="category-amount">
¥{{ formatMoney(category.amount) }}
</div>
<div class="category-percent">
{{ category.percent }}%
</div>
</div>
<van-icon
name="arrow"
class="category-arrow"
/>
</div>
<!-- 展开/收起按钮 -->
<div
v-if="expenseCategoriesView.length > 1"
class="expand-toggle"
@click="showAllExpense = !showAllExpense"
>
<van-icon :name="showAllExpense ? 'arrow-up' : 'arrow-down'" />
</div>
</div>
<ModernEmpty
v-if="!expenseCategoriesView || !expenseCategoriesView.length"
type="chart"
theme="blue"
title="暂无支出"
description="本期还没有支出记录"
size="small"
/>
</div>
</template>
<script setup>
import { ref, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import * as echarts from 'echarts'
import { getCssVar } from '@/utils/theme'
import ModernEmpty from '@/components/ModernEmpty.vue'
const props = defineProps({
categories: {
type: Array,
default: () => []
},
totalExpense: {
type: Number,
default: 0
},
colors: {
type: Array,
default: () => []
}
})
defineEmits(['category-click'])
const pieChartRef = ref(null)
let pieChartInstance = null
const showAllExpense = ref(false)
// 格式化金额
const formatMoney = (value) => {
if (!value && value !== 0) {
return '0'
}
return Number(value)
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// 计算属性
const expenseCategoriesView = computed(() => {
const list = [...props.categories]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
const expenseCategoriesSimpView = computed(() => {
const list = expenseCategoriesView.value
if (showAllExpense.value) {
return list
}
// 只展示未分类
const unclassified = list.filter((c) => c.classify === '未分类' || !c.classify)
if (unclassified.length > 0) {
return [...unclassified]
}
return []
})
// 渲染饼图
const renderPieChart = () => {
if (!pieChartRef.value) {
return
}
if (expenseCategoriesView.value.length === 0) {
return
}
// 尝试获取DOM上的现有实例
const existingInstance = echarts.getInstanceByDom(pieChartRef.value)
if (pieChartInstance && pieChartInstance !== existingInstance) {
if (!pieChartInstance.isDisposed()) {
pieChartInstance.dispose()
}
pieChartInstance = null
}
if (pieChartInstance && pieChartInstance.getDom() !== pieChartRef.value) {
pieChartInstance.dispose()
pieChartInstance = null
}
if (!pieChartInstance && existingInstance) {
pieChartInstance = existingInstance
}
if (!pieChartInstance) {
pieChartInstance = echarts.init(pieChartRef.value)
}
// 使用 Top N + Other 的数据逻辑,确保图表不会太拥挤
const list = [...expenseCategoriesView.value]
let chartData = []
// 按照金额排序
list.sort((a, b) => b.amount - a.amount)
const MAX_SLICES = 8 // 最大显示扇区数,其余合并为"其他"
if (list.length > MAX_SLICES) {
const topList = list.slice(0, MAX_SLICES - 1)
const otherList = list.slice(MAX_SLICES - 1)
const otherAmount = otherList.reduce((sum, item) => sum + item.amount, 0)
chartData = topList.map((item, index) => ({
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: props.colors[index % props.colors.length] }
}))
chartData.push({
value: otherAmount,
name: '其他',
itemStyle: { color: getCssVar('--van-gray-6') } // 使用灰色表示其他
})
} else {
chartData = list.map((item, index) => ({
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: props.colors[index % props.colors.length] }
}))
}
const option = {
title: {
text: '¥' + formatMoney(props.totalExpense),
subtext: '总支出',
left: 'center',
top: 'center',
textStyle: {
color: getCssVar('--chart-text-muted'), // 适配深色模式
fontSize: 20,
fontWeight: 'bold'
},
subtextStyle: {
color: getCssVar('--chart-text-muted'),
fontSize: 13
}
},
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.name}: ¥${formatMoney(params.value)} (${params.percent.toFixed(1)}%)`
}
},
series: [
{
name: '支出分类',
type: 'pie',
radius: ['50%', '80%'],
avoidLabelOverlap: true,
minAngle: 5, // 最小扇区角度,防止扇区太小看不见
itemStyle: {
borderRadius: 5,
borderColor: getCssVar('--van-background-2'),
borderWidth: 2
},
label: {
show: false
},
labelLine: {
show: false
},
data: chartData
}
]
}
pieChartInstance.setOption(option)
}
// 监听数据变化重新渲染图表
watch(() => [props.categories, props.totalExpense, props.colors], () => {
nextTick(() => {
renderPieChart()
})
}, { deep: true, immediate: true })
// 组件销毁时清理图表实例
onBeforeUnmount(() => {
if (pieChartInstance && !pieChartInstance.isDisposed()) {
pieChartInstance.dispose()
}
})
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
// 通用卡片样式
.common-card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
/* 环形图 */
.chart-container {
padding: 0;
}
.ring-chart {
position: relative;
width: 100%;
height: 200px;
margin: 0 auto;
}
/* 分类列表 */
.category-list {
padding: 0;
}
.category-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--van-border-color);
transition: background-color 0.2s;
gap: 12px;
}
.category-item:last-child {
border-bottom: none;
}
.category-item.clickable {
cursor: pointer;
}
.category-item.clickable:active {
background-color: var(--van-background);
}
.category-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.category-name-with-count {
display: flex;
align-items: center;
gap: 8px;
}
.category-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.category-name {
font-size: 14px;
color: var(--van-text-color);
}
.category-count {
font-size: 12px;
color: var(--van-text-color-3);
}
.category-stats {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.category-arrow {
margin-left: 8px;
color: var(--van-text-color-3);
font-size: 16px;
flex-shrink: 0;
}
.expand-toggle {
display: flex;
justify-content: center;
align-items: center;
padding-top: 0;
color: var(--van-text-color-3);
font-size: 20px;
cursor: pointer;
}
.expand-toggle:active {
opacity: 0.7;
}
.category-amount {
font-size: 15px;
font-weight: 600;
color: var(--van-text-color);
}
.category-percent {
font-size: 12px;
color: var(--van-text-color-3);
background: var(--van-background);
padding: 2px 8px;
border-radius: 10px;
}
</style>

View File

@@ -0,0 +1,272 @@
<template>
<!-- 收支和不计收支并列显示 -->
<div class="side-by-side-cards">
<!-- 收入分类统计 -->
<div class="common-card half-card">
<div class="card-header">
<h3 class="card-title">
收入
<span
class="income-text"
style="font-size: 13px; margin-left: 4px"
>
¥{{ formatMoney(totalIncome) }}
</span>
</h3>
<van-tag
type="success"
size="medium"
>
{{ incomeCategories.length }}
</van-tag>
</div>
<div
v-if="incomeCategories.length > 0"
class="category-list"
>
<div
v-for="category in incomeCategories"
:key="category.classify"
class="category-item clickable"
@click="$emit('category-click', category.classify, 1)"
>
<div class="category-info">
<div class="category-color income-color" />
<span class="category-name text-ellipsis">{{ category.classify || '未分类' }}</span>
</div>
<div class="category-amount income-text">
¥{{ formatMoney(category.amount) }}
</div>
</div>
</div>
<ModernEmpty
v-else
type="finance"
theme="green"
title="暂无收入"
description="本期还没有收入记录"
size="small"
/>
</div>
<!-- 不计收支分类统计 -->
<div class="common-card half-card">
<div class="card-header">
<h3 class="card-title">
不计收支
</h3>
<van-tag
type="warning"
size="medium"
>
{{ noneCategories.length }}
</van-tag>
</div>
<div
v-if="noneCategories.length > 0"
class="category-list"
>
<div
v-for="category in noneCategories"
:key="category.classify"
class="category-item clickable"
@click="$emit('category-click', category.classify, 2)"
>
<div class="category-info">
<div class="category-color none-color" />
<span class="category-name text-ellipsis">{{ category.classify || '未分类' }}</span>
</div>
<div class="category-amount none-text">
¥{{ formatMoney(category.amount) }}
</div>
</div>
</div>
<ModernEmpty
v-else
type="inbox"
theme="gray"
title="暂无数据"
description="本期没有不计收支记录"
size="small"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import ModernEmpty from '@/components/ModernEmpty.vue'
const props = defineProps({
incomeCategories: {
type: Array,
default: () => []
},
noneCategories: {
type: Array,
default: () => []
},
totalIncome: {
type: Number,
default: 0
}
})
defineEmits(['category-click'])
// 格式化金额
const formatMoney = (value) => {
if (!value && value !== 0) {
return '0'
}
return Number(value)
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// 处理未分类排序
const incomeCategories = computed(() => {
const list = [...props.incomeCategories]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
const noneCategories = computed(() => {
const list = [...props.noneCategories]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
// 通用卡片样式
.common-card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
/* 并列显示卡片 */
.side-by-side-cards {
display: flex;
gap: 12px;
margin: 0 12px 16px;
}
.side-by-side-cards .common-card {
margin: 0;
flex: 1;
min-width: 0; /* 允许内部元素缩小 */
padding: 12px;
}
.card-header {
margin-bottom: 0;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
/* 分类列表 */
.category-list {
padding: 0;
}
.category-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--van-border-color);
transition: background-color 0.2s;
gap: 12px;
}
.category-item:last-child {
border-bottom: none;
}
.category-item.clickable {
cursor: pointer;
}
.category-item.clickable:active {
background-color: var(--van-background);
}
.category-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.category-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.category-name {
font-size: 14px;
color: var(--van-text-color);
}
.category-amount {
font-size: 15px;
font-weight: 600;
color: var(--van-text-color);
flex-shrink: 0;
}
.income-color {
background-color: var(--van-success-color);
}
.income-text {
color: var(--van-success-color);
}
/* 不计收支颜色 */
.none-color {
background-color: var(--van-gray-6);
}
.none-text {
color: var(--van-gray-6);
}
</style>

View File

@@ -0,0 +1,787 @@
<template>
<van-config-provider :theme="theme">
<div class="page-container-flex statistics-v2-wrapper">
<!-- 头部年月选择器 -->
<CalendarHeader
:type="currentPeriod"
:current-date="currentDate"
:show-notification="true"
@prev="handlePrevPeriod"
@next="handleNextPeriod"
@jump="showDatePicker = true"
@notification="goToStatisticsV1"
/>
<div>
<!-- 时间段选择器 -->
<TimePeriodTabs
:active-tab="currentPeriod"
@change="handlePeriodChange"
/>
</div>
<!-- 可滚动内容区域 -->
<div class="statistics-scroll-content">
<!-- 下拉刷新 -->
<van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
>
<!-- 加载状态 -->
<van-loading
v-if="loading"
vertical
style="padding: 100px 0"
>
加载统计数据中...
</van-loading>
<!-- 错误状态 -->
<div
v-else-if="hasError"
class="error-state"
>
<van-empty
image="error"
:description="errorMessage || '加载数据时出现错误'"
>
<van-button
type="primary"
size="small"
@click="retryLoad"
>
重试
</van-button>
</van-empty>
</div>
<!-- 统计内容 -->
<div
v-else
class="statistics-content"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- 收支结余和趋势卡片合并 -->
<MonthlyExpenseCard
:amount="monthlyStats.totalExpense"
:income="monthlyStats.totalIncome"
:balance="monthlyStats.balance"
:trend-data="trendStats"
:period="currentPeriod"
:current-date="currentDate"
/>
<!-- 支出分类卡片 -->
<ExpenseCategoryCard
:categories="expenseCategories"
:total-expense="monthlyStats.totalExpense"
:colors="categoryColors"
@category-click="goToCategoryBills"
/>
<!-- 收入和不计收支分类卡片 -->
<IncomeNoneCategoryCard
:income-categories="incomeCategories"
:none-categories="noneCategories"
:total-income="monthlyStats.totalIncome"
@category-click="goToCategoryBills"
/>
</div>
</van-pull-refresh>
</div>
<!-- 日期选择器 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
:style="{ height: '50%' }"
>
<van-date-picker
v-model="selectedDate"
:type="datePickerType"
:min-date="minDate"
:max-date="maxDate"
@confirm="onDateConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 液态玻璃底部导航栏 -->
<GlassBottomNav
v-model="activeTab"
@tab-click="handleTabClick"
/>
<!-- 分类账单弹窗 -->
<CategoryBillPopup
v-model="billPopupVisible"
:classify="selectedClassify"
:type="selectedType"
:year="currentDate.getFullYear()"
:month="currentPeriod === 'year' ? 0 : currentDate.getMonth() + 1"
@refresh="loadStatistics"
/>
</div>
</van-config-provider>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import CalendarHeader from '@/components/DateSelectHeader.vue'
import TimePeriodTabs from '@/components/TimePeriodTabs.vue'
import MonthlyExpenseCard from './modules/MonthlyExpenseCard.vue'
import ExpenseCategoryCard from './modules/ExpenseCategoryCard.vue'
import IncomeNoneCategoryCard from './modules/IncomeNoneCategoryCard.vue'
import CategoryBillPopup from '@/components/CategoryBillPopup.vue'
import GlassBottomNav from '@/components/GlassBottomNav.vue'
import {
// 新统一接口
getDailyStatisticsByRange,
getSummaryByRange,
getCategoryStatisticsByRange,
getTrendStatistics,
// 旧接口(兼容性保留)
getMonthlyStatistics,
getCategoryStatistics,
getDailyStatistics
} from '@/api/statistics'
import { useMessageStore } from '@/stores/message'
import { getCssVar } from '@/utils/theme'
// 为组件缓存设置名称
defineOptions({
name: 'StatisticsV2View'
})
const router = useRouter()
const messageStore = useMessageStore()
// 主题
const theme = computed(() => messageStore.isDarkMode ? 'dark' : 'light')
// 底部导航栏
const activeTab = ref('statistics')
const handleTabClick = (_item, _index) => {
// 导航逻辑已在组件内部处理
}
// 状态管理
const loading = ref(false)
const refreshing = ref(false)
const showDatePicker = ref(false)
const errorMessage = ref('')
const hasError = ref(false)
// 分类账单弹窗状态
const billPopupVisible = ref(false)
const selectedClassify = ref('')
const selectedType = ref(0)
// 触摸滑动相关状态
const touchStartX = ref(0)
const touchStartY = ref(0)
const touchEndX = ref(0)
const touchEndY = ref(0)
// 时间段选择
const currentPeriod = ref('month')
const currentDate = ref(new Date())
const selectedDate = ref([])
const minDate = new Date(2020, 0, 1)
const maxDate = new Date()
// 统计数据
const monthlyStats = ref({
totalExpense: 0,
totalIncome: 0,
balance: 0,
expenseCount: 0,
incomeCount: 0
})
const trendStats = ref([])
const expenseCategories = ref([])
const incomeCategories = ref([])
const noneCategories = ref([])
// 颜色配置
const categoryColors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9'
]
// 计算属性
const datePickerType = computed(() => {
switch (currentPeriod.value) {
case 'week':
case 'month':
return 'year-month'
case 'year':
return 'year'
default:
return 'year-month'
}
})
// 获取周的开始日期(周一)
const getWeekStartDate = (date) => {
const target = new Date(date.valueOf())
const dayNr = (date.getDay() + 6) % 7 // 周一为0周日为6
target.setDate(target.getDate() - dayNr)
target.setHours(0, 0, 0, 0)
return target
}
// 格式化日期为字符串
const formatDateToString = (date) => {
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${year}-${month}-${day}`
}
// 加载统计数据
const loadStatistics = async () => {
if (loading.value && !refreshing.value) {
return // 防止重复加载
}
loading.value = !refreshing.value
try {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth() + 1
// 重置数据
monthlyStats.value = {
totalExpense: 0,
totalIncome: 0,
balance: 0,
expenseCount: 0,
incomeCount: 0
}
trendStats.value = []
expenseCategories.value = []
incomeCategories.value = []
noneCategories.value = []
// 根据时间段加载不同的数据
if (currentPeriod.value === 'month') {
await loadMonthlyData(year, month)
} else if (currentPeriod.value === 'year') {
await loadYearlyData(year)
} else if (currentPeriod.value === 'week') {
await loadWeeklyData()
}
// 加载分类统计
await loadCategoryStatistics(year, month)
} catch (error) {
console.error('加载统计数据失败:', error)
hasError.value = true
errorMessage.value = error.message || '网络连接异常,请检查网络后重试'
} finally {
loading.value = false
}
}
// 重试加载
const retryLoad = () => {
hasError.value = false
errorMessage.value = ''
loadStatistics()
}
// 加载月度数据
const loadMonthlyData = async (year, month) => {
try {
// 月度统计
const monthlyResult = await getMonthlyStatistics({ year, month })
if (monthlyResult?.success && monthlyResult.data) {
monthlyStats.value = {
totalExpense: monthlyResult.data.totalExpense || 0,
totalIncome: monthlyResult.data.totalIncome || 0,
balance: (monthlyResult.data.totalIncome || 0) - (monthlyResult.data.totalExpense || 0),
expenseCount: monthlyResult.data.expenseCount || 0,
incomeCount: monthlyResult.data.incomeCount || 0
}
}
// 加载每日统计
const dailyResult = await getDailyStatistics({ year, month })
if (dailyResult?.success && dailyResult.data) {
// 转换数据格式:添加完整的 date 字段
trendStats.value = dailyResult.data
.filter(item => item != null)
.map(item => ({
date: `${year}-${month.toString().padStart(2, '0')}-${item.day.toString().padStart(2, '0')}`,
expense: item.expense || 0,
income: item.income || 0,
count: item.count || 0
}))
}
} catch (error) {
console.error('加载月度数据失败:', error)
}
}
// 加载年度数据
const loadYearlyData = async (year) => {
try {
// 年度统计 - 使用趋势接口获取12个月数据
const trendResult = await getTrendStatistics({ startYear: year, startMonth: 1, monthCount: 12 })
if (trendResult?.success && trendResult.data) {
// 计算年度汇总
const yearTotal = trendResult.data.reduce((acc, item) => {
const expense = item.expense || 0
const income = item.income || 0
return {
totalExpense: acc.totalExpense + expense,
totalIncome: acc.totalIncome + income,
balance: acc.balance + income - expense
}
}, { totalExpense: 0, totalIncome: 0, balance: 0 })
monthlyStats.value = {
...yearTotal,
expenseCount: 0,
incomeCount: 0
}
trendStats.value = trendResult.data.map(item => ({
date: `${item.year}-${item.month.toString().padStart(2, '0')}-01`,
amount: (item.income || 0) - (item.expense || 0),
count: 1
}))
}
} catch (error) {
console.error('加载年度数据失败:', error)
}
}
// 加载周度数据
const loadWeeklyData = async () => {
try {
// 周统计 - 计算当前周的开始和结束日期
const weekStart = getWeekStartDate(currentDate.value)
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekStart.getDate() + 7) // 修改:+7 天,因为 endDate 是不包含的
const startDateStr = formatDateToString(weekStart)
const endDateStr = formatDateToString(weekEnd)
// 使用新的统一接口获取周统计汇总
const weekSummaryResult = await getSummaryByRange({
startDate: startDateStr,
endDate: endDateStr
})
if (weekSummaryResult?.success && weekSummaryResult.data) {
monthlyStats.value = {
totalExpense: weekSummaryResult.data.totalExpense || 0,
totalIncome: weekSummaryResult.data.totalIncome || 0,
balance: (weekSummaryResult.data.totalIncome || 0) - (weekSummaryResult.data.totalExpense || 0),
expenseCount: weekSummaryResult.data.expenseCount || 0,
incomeCount: weekSummaryResult.data.incomeCount || 0
}
}
// 使用新的统一接口获取周内每日统计
const dailyResult = await getDailyStatisticsByRange({
startDate: startDateStr,
endDate: endDateStr
})
if (dailyResult?.success && dailyResult.data) {
// 转换数据格式以适配图表组件
trendStats.value = dailyResult.data.map(item => ({
date: item.date,
amount: (item.income || 0) - (item.expense || 0),
count: item.count || 0
}))
}
} catch (error) {
console.error('加载周度数据失败:', error)
}
}
// 加载分类统计
const loadCategoryStatistics = async (year, month) => {
try {
const categoryYear = year
// 如果是年度统计month应该传0表示查询全年
const categoryMonth = currentPeriod.value === 'year' ? 0 : month
// 对于周统计,使用日期范围进行分类统计
if (currentPeriod.value === 'week') {
const weekStart = getWeekStartDate(currentDate.value)
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekStart.getDate() + 7) // 修改:+7 天,因为 endDate 是不包含的
const startDateStr = formatDateToString(weekStart)
const endDateStr = formatDateToString(weekEnd)
// 使用新的统一接口并发加载支出、收入和不计收支分类
const [expenseResult, incomeResult, noneResult] = await Promise.allSettled([
getCategoryStatisticsByRange({ startDate: startDateStr, endDate: endDateStr, type: 0 }),
getCategoryStatisticsByRange({ startDate: startDateStr, endDate: endDateStr, type: 1 }),
getCategoryStatisticsByRange({ startDate: startDateStr, endDate: endDateStr, type: 2 })
])
// 获取图表颜色配置
const getChartColors = () => [
getCssVar('--chart-color-1'),
getCssVar('--chart-color-2'),
getCssVar('--chart-color-3'),
getCssVar('--chart-color-4'),
getCssVar('--chart-color-5'),
getCssVar('--chart-color-6'),
getCssVar('--chart-color-7'),
getCssVar('--chart-color-8'),
getCssVar('--chart-color-9'),
getCssVar('--chart-color-10'),
getCssVar('--chart-color-11'),
getCssVar('--chart-color-12'),
getCssVar('--chart-color-13'),
getCssVar('--chart-color-14'),
getCssVar('--chart-color-15')
]
const currentColors = getChartColors()
// 处理支出分类结果
if (expenseResult.status === 'fulfilled' && expenseResult.value?.success && expenseResult.value.data) {
expenseCategories.value = expenseResult.value.data.map((item, index) => ({
classify: item.classify,
amount: item.amount || 0,
count: item.count || 0,
percent: item.percent || 0,
color: currentColors[index % currentColors.length]
}))
}
// 处理收入分类结果
if (incomeResult.status === 'fulfilled' && incomeResult.value?.success && incomeResult.value.data) {
incomeCategories.value = incomeResult.value.data.map((item) => ({
classify: item.classify,
amount: item.amount || 0,
count: item.count || 0,
percent: item.percent || 0
}))
}
// 处理不计收支分类结果
if (noneResult.status === 'fulfilled' && noneResult.value?.success && noneResult.value.data) {
noneCategories.value = noneResult.value.data.map((item) => ({
classify: item.classify,
amount: item.amount || 0,
count: item.count || 0,
percent: item.percent || 0
}))
}
} else {
// 对于月度和年度统计,使用年月进行分类统计
// 并发加载支出、收入和不计收支分类
const [expenseResult, incomeResult, noneResult] = await Promise.allSettled([
getCategoryStatistics({ year: categoryYear, month: categoryMonth, type: 0 }),
getCategoryStatistics({ year: categoryYear, month: categoryMonth, type: 1 }),
getCategoryStatistics({ year: categoryYear, month: categoryMonth, type: 2 })
])
// 获取图表颜色配置
const getChartColors = () => [
getCssVar('--chart-color-1'),
getCssVar('--chart-color-2'),
getCssVar('--chart-color-3'),
getCssVar('--chart-color-4'),
getCssVar('--chart-color-5'),
getCssVar('--chart-color-6'),
getCssVar('--chart-color-7'),
getCssVar('--chart-color-8'),
getCssVar('--chart-color-9'),
getCssVar('--chart-color-10'),
getCssVar('--chart-color-11'),
getCssVar('--chart-color-12'),
getCssVar('--chart-color-13'),
getCssVar('--chart-color-14'),
getCssVar('--chart-color-15')
]
const currentColors = getChartColors()
// 处理支出分类结果
if (expenseResult.status === 'fulfilled' && expenseResult.value?.success && expenseResult.value.data) {
expenseCategories.value = expenseResult.value.data.map((item, index) => ({
classify: item.classify,
amount: item.amount || 0,
count: item.count || 0,
percent: item.percent || 0,
color: currentColors[index % currentColors.length]
}))
}
// 处理收入分类结果
if (incomeResult.status === 'fulfilled' && incomeResult.value?.success && incomeResult.value.data) {
incomeCategories.value = incomeResult.value.data.map((item) => ({
classify: item.classify,
amount: item.amount || 0,
count: item.count || 0,
percent: item.percent || 0
}))
}
// 处理不计收支分类结果
if (noneResult.status === 'fulfilled' && noneResult.value?.success && noneResult.value.data) {
noneCategories.value = noneResult.value.data.map((item) => ({
classify: item.classify,
amount: item.amount || 0,
count: item.count || 0,
percent: item.percent || 0
}))
}
}
} catch (error) {
console.error('加载分类统计失败:', error)
}
}
// 处理时间段切换
const handlePeriodChange = (period) => {
currentPeriod.value = period
// 清除错误状态
hasError.value = false
errorMessage.value = ''
loadStatistics()
}
// 切换时间周期
const handlePrevPeriod = () => {
const newDate = new Date(currentDate.value)
switch (currentPeriod.value) {
case 'week':
newDate.setDate(newDate.getDate() - 7)
break
case 'month':
newDate.setMonth(newDate.getMonth() - 1)
break
case 'year':
newDate.setFullYear(newDate.getFullYear() - 1)
break
}
currentDate.value = newDate
// 清除错误状态
hasError.value = false
errorMessage.value = ''
loadStatistics()
}
const handleNextPeriod = () => {
// 检查是否已经是最后一个周期(当前周期)
if (isLastPeriod()) {
return
}
const newDate = new Date(currentDate.value)
switch (currentPeriod.value) {
case 'week':
newDate.setDate(newDate.getDate() + 7)
break
case 'month':
newDate.setMonth(newDate.getMonth() + 1)
break
case 'year':
newDate.setFullYear(newDate.getFullYear() + 1)
break
}
currentDate.value = newDate
// 清除错误状态
hasError.value = false
errorMessage.value = ''
loadStatistics()
}
// 判断是否是最后一个周期(不能再往后切换)
const isLastPeriod = () => {
const now = new Date()
const current = new Date(currentDate.value)
switch (currentPeriod.value) {
case 'week': {
// 获取当前周的开始日期和当前时间所在周的开始日期
const currentWeekStart = getWeekStartDate(current)
const nowWeekStart = getWeekStartDate(now)
return currentWeekStart >= nowWeekStart
}
case 'month': {
// 比较年月
return current.getFullYear() === now.getFullYear() &&
current.getMonth() === now.getMonth()
}
case 'year': {
// 比较年份
return current.getFullYear() === now.getFullYear()
}
default:
return false
}
}
// 触摸事件处理
const handleTouchStart = (e) => {
touchStartX.value = e.touches[0].clientX
touchStartY.value = e.touches[0].clientY
}
const handleTouchMove = (e) => {
touchEndX.value = e.touches[0].clientX
touchEndY.value = e.touches[0].clientY
}
const handleTouchEnd = () => {
const deltaX = touchEndX.value - touchStartX.value
const deltaY = touchEndY.value - touchStartY.value
// 判断是否是水平滑动(水平距离大于垂直距离)
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
if (deltaX > 0) {
// 右滑 - 上一个周期
handlePrevPeriod()
} else {
// 左滑 - 下一个周期
handleNextPeriod()
}
}
// 重置触摸位置
touchStartX.value = 0
touchStartY.value = 0
touchEndX.value = 0
touchEndY.value = 0
}
// 下拉刷新
const onRefresh = async () => {
// 清除错误状态
hasError.value = false
errorMessage.value = ''
await loadStatistics()
refreshing.value = false
}
// 日期选择确认
const onDateConfirm = ({ selectedValues }) => {
if (currentPeriod.value === 'year') {
const [year] = selectedValues
currentDate.value = new Date(year, 0, 1)
} else {
const [year, month] = selectedValues
currentDate.value = new Date(year, month - 1, 1)
}
showDatePicker.value = false
// 清除错误状态
hasError.value = false
errorMessage.value = ''
loadStatistics()
}
// 跳转到分类账单
const goToCategoryBills = (classify, type) => {
selectedClassify.value = classify || ''
selectedType.value = type
billPopupVisible.value = true
}
// 切换到统计V1页面
const goToStatisticsV1 = () => {
router.push({ name: 'statistics' })
}
// 监听时间段变化,更新选中日期
watch(currentPeriod, () => {
if (currentPeriod.value === 'year') {
selectedDate.value = [currentDate.value.getFullYear()]
} else {
selectedDate.value = [
currentDate.value.getFullYear(),
currentDate.value.getMonth() + 1
]
}
})
// 初始化
onMounted(() => {
// 设置默认选中日期
selectedDate.value = [
currentDate.value.getFullYear(),
currentDate.value.getMonth() + 1
]
loadStatistics()
})
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
/* ========== 页面容器 ========== */
.statistics-v2-wrapper {
font-family: var(--font-primary);
color: var(--text-primary);
height: 100vh;
display: flex;
flex-direction: column;
}
.statistics-scroll-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
background-color: var(--bg-secondary);
/* 改善滚动性能 */
will-change: scroll-position;
/* 防止滚动卡顿 */
scroll-behavior: smooth;
}
.statistics-content {
padding: var(--spacing-md);
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
min-height: 100%;
/* 确保内容足够高以便滚动 */
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.error-state {
padding: var(--spacing-3xl) var(--spacing-md);
text-align: center;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* 在移动设备上优化滚动体验 */
@media (max-width: 768px) {
.statistics-scroll-content {
/* iOS Safari 优化 */
-webkit-overflow-scrolling: touch;
/* 防止橡皮筋效果 */
overscroll-behavior-y: contain;
}
.statistics-content {
padding: var(--spacing-sm);
padding-bottom: calc(90px + env(safe-area-inset-bottom, 0px));
}
}
</style>

View File

@@ -0,0 +1,361 @@
<template>
<div class="daily-trend-card common-card">
<div class="card-header">
<h3 class="card-title">
{{ chartTitle }} (收支)
</h3>
</div>
<div
ref="chartRef"
class="trend-chart"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import { useMessageStore } from '@/stores/message'
const props = defineProps({
data: {
type: Array,
default: () => []
},
period: {
type: String,
default: 'month'
},
currentDate: {
type: Date,
required: true
}
})
const messageStore = useMessageStore()
const chartRef = ref()
let chartInstance = null
// 计算图表标题
const chartTitle = computed(() => {
switch (props.period) {
case 'week':
return '每日趋势'
case 'month':
return '每日趋势'
case 'year':
return '每月趋势'
default:
return '趋势'
}
})
// 获取月份天数
const getDaysInMonth = (year, month) => {
return new Date(year, month, 0).getDate()
}
// 初始化图表
const initChart = async () => {
await nextTick()
if (!chartRef.value) {
console.warn('图表容器未找到')
return
}
// 销毁已存在的图表实例
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
try {
chartInstance = echarts.init(chartRef.value)
updateChart()
} catch (error) {
console.error('初始化图表失败:', error)
}
}
// 更新图表
const updateChart = () => {
if (!chartInstance) {
console.warn('图表实例不存在')
return
}
// 验证数据
if (!Array.isArray(props.data)) {
console.warn('图表数据格式错误')
return
}
// 根据时间段类型和数据来生成图表
let chartData = []
let xAxisLabels = []
try {
if (props.period === 'week') {
// 周统计:直接使用传入的数据,按日期排序
chartData = [...props.data].sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map(item => {
const date = new Date(item.date)
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
return weekDays[date.getDay()]
})
} else if (props.period === 'month') {
// 月统计:生成完整的月份数据
const currentDate = props.currentDate
const year = currentDate.getFullYear()
const month = currentDate.getMonth() + 1
const daysInMonth = getDaysInMonth(year, month)
const allDays = Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1
const paddedDay = day.toString().padStart(2, '0')
return `${year}-${month.toString().padStart(2, '0')}-${paddedDay}`
})
// 创建完整的数据映射
const dataMap = new Map()
props.data.forEach(item => {
if (item && item.date) {
dataMap.set(item.date, item)
}
})
// 生成完整的数据序列
chartData = allDays.map(date => {
const dayData = dataMap.get(date)
return {
date,
amount: dayData?.amount || 0,
count: dayData?.count || 0
}
})
xAxisLabels = chartData.map((_, index) => (index + 1).toString())
} else if (props.period === 'year') {
// 年统计:直接使用数据,显示月份标签
chartData = [...props.data]
.filter(item => item && item.date)
.sort((a, b) => new Date(a.date) - new Date(b.date))
xAxisLabels = chartData.map(item => {
const date = new Date(item.date)
return `${date.getMonth() + 1}`
})
}
// 如果没有有效数据,显示空图表
if (chartData.length === 0) {
const option = {
backgroundColor: 'transparent',
graphic: [{
type: 'text',
left: 'center',
top: 'middle',
style: {
text: '暂无数据',
fontSize: 16,
fill: messageStore.isDarkMode ? '#9CA3AF' : '#6B7280'
}
}]
}
chartInstance.setOption(option)
return
}
// 准备图表数据
const expenseData = chartData.map(item => {
const amount = item.amount || 0
return amount < 0 ? Math.abs(amount) : 0
})
const incomeData = chartData.map(item => {
const amount = item.amount || 0
return amount > 0 ? amount : 0
})
const isDark = messageStore.isDarkMode
const option = {
backgroundColor: 'transparent',
grid: {
top: 20,
left: 10,
right: 10,
bottom: 20,
containLabel: false
},
xAxis: {
type: 'category',
data: xAxisLabels,
show: false
},
yAxis: {
type: 'value',
show: false
},
series: [
// 支出线
{
name: '支出',
type: 'line',
data: expenseData,
smooth: true,
symbol: 'none',
lineStyle: {
color: '#ff6b6b',
width: 2
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(255, 107, 107, 0.3)' },
{ offset: 1, color: 'rgba(255, 107, 107, 0.05)' }
]
}
}
},
// 收入线
{
name: '收入',
type: 'line',
data: incomeData,
smooth: true,
symbol: 'none',
lineStyle: {
color: '#4ade80',
width: 2
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(74, 222, 128, 0.3)' },
{ offset: 1, color: 'rgba(74, 222, 128, 0.05)' }
]
}
}
}
],
tooltip: {
trigger: 'axis',
backgroundColor: isDark ? 'rgba(39, 39, 42, 0.95)' : 'rgba(255, 255, 255, 0.95)',
borderColor: isDark ? 'rgba(63, 63, 70, 0.8)' : 'rgba(229, 231, 235, 0.8)',
textStyle: {
color: isDark ? '#f4f4f5' : '#1a1a1a'
},
formatter: (params) => {
if (!params || params.length === 0 || !chartData[params[0].dataIndex]) {
return ''
}
const date = chartData[params[0].dataIndex].date
let content = ''
try {
if (props.period === 'week') {
const dateObj = new Date(date)
const month = dateObj.getMonth() + 1
const day = dateObj.getDate()
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
const weekDay = weekDays[dateObj.getDay()]
content = `${month}${day}日 (周${weekDay})<br/>`
} else if (props.period === 'month') {
const day = new Date(date).getDate()
content = `${props.currentDate.getMonth() + 1}${day}日<br/>`
} else if (props.period === 'year') {
const dateObj = new Date(date)
content = `${dateObj.getFullYear()}${dateObj.getMonth() + 1}月<br/>`
}
params.forEach((param) => {
if (param.value > 0) {
const color = param.seriesName === '支出' ? '#ff6b6b' : '#4ade80'
content += `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${color}"></span>`
content += `${param.seriesName}: ¥${param.value.toFixed(2)}<br/>`
}
})
} catch (error) {
console.warn('格式化tooltip失败:', error)
content = '数据格式错误'
}
return content
}
}
}
chartInstance.setOption(option)
} catch (error) {
console.error('更新图表失败:', error)
}
}
// 监听数据变化
watch(() => props.data, () => {
if (chartInstance) {
updateChart()
}
}, { deep: true })
// 监听主题变化
watch(() => messageStore.isDarkMode, () => {
if (chartInstance) {
updateChart()
}
})
onMounted(() => {
initChart()
})
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.dispose()
}
})
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
.daily-trend-card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
.trend-chart {
width: 100%;
height: 180px;
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<CategoryCard
title="支出分类"
:categories="categories"
:type="0"
tag-type="primary"
empty-description="本期暂无支出记录"
@category-click="handleCategoryClick"
/>
</template>
<script setup>
import CategoryCard from '@/components/Statistics/CategoryCard.vue'
defineProps({
categories: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['category-click'])
const handleCategoryClick = (classify, type) => {
emit('category-click', classify, type)
}
</script>

View File

@@ -0,0 +1,407 @@
<template>
<!-- 支出分类统计 -->
<div
class="common-card"
style="padding-bottom: 10px;"
>
<div class="card-header">
<h3 class="card-title">
支出分类
</h3>
<van-tag
type="primary"
size="medium"
>
{{ expenseCategoriesView.length }}
</van-tag>
</div>
<!-- 环形图区域 -->
<div
v-if="expenseCategoriesView.length > 0"
class="chart-container"
>
<div class="ring-chart">
<div
ref="pieChartRef"
style="width: 100%; height: 100%"
/>
</div>
</div>
<!-- 分类列表 -->
<div class="category-list">
<div
v-for="category in expenseCategoriesSimpView"
:key="category.classify"
class="category-item clickable"
@click="$emit('category-click', category.classify, 0)"
>
<div class="category-info">
<div
class="category-color"
:style="{ backgroundColor: category.color }"
/>
<div class="category-name-with-count">
<span class="category-name">{{ category.classify || '未分类' }}</span>
<span class="category-count">{{ category.count }}</span>
</div>
</div>
<div class="category-stats">
<div class="category-amount">
¥{{ formatMoney(category.amount) }}
</div>
<div class="category-percent">
{{ category.percent }}%
</div>
</div>
<van-icon
name="arrow"
class="category-arrow"
/>
</div>
<!-- 展开/收起按钮 -->
<div
v-if="expenseCategoriesView.length > 1"
class="expand-toggle"
@click="showAllExpense = !showAllExpense"
>
<van-icon :name="showAllExpense ? 'arrow-up' : 'arrow-down'" />
</div>
</div>
<ModernEmpty
v-if="!expenseCategoriesView || !expenseCategoriesView.length"
type="chart"
theme="blue"
title="暂无支出"
description="本期还没有支出记录"
size="small"
/>
</div>
</template>
<script setup>
import { ref, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import * as echarts from 'echarts'
import { getCssVar } from '@/utils/theme'
import ModernEmpty from '@/components/ModernEmpty.vue'
const props = defineProps({
categories: {
type: Array,
default: () => []
},
totalExpense: {
type: Number,
default: 0
},
colors: {
type: Array,
default: () => []
}
})
defineEmits(['category-click'])
const pieChartRef = ref(null)
let pieChartInstance = null
const showAllExpense = ref(false)
// 格式化金额
const formatMoney = (value) => {
if (!value && value !== 0) {
return '0'
}
return Number(value)
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// 计算属性
const expenseCategoriesView = computed(() => {
const list = [...props.categories]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
const expenseCategoriesSimpView = computed(() => {
const list = expenseCategoriesView.value
if (showAllExpense.value) {
return list
}
// 只展示未分类
const unclassified = list.filter((c) => c.classify === '未分类' || !c.classify)
if (unclassified.length > 0) {
return [...unclassified]
}
return []
})
// 渲染饼图
const renderPieChart = () => {
if (!pieChartRef.value) {
return
}
if (expenseCategoriesView.value.length === 0) {
return
}
// 尝试获取DOM上的现有实例
const existingInstance = echarts.getInstanceByDom(pieChartRef.value)
if (pieChartInstance && pieChartInstance !== existingInstance) {
if (!pieChartInstance.isDisposed()) {
pieChartInstance.dispose()
}
pieChartInstance = null
}
if (pieChartInstance && pieChartInstance.getDom() !== pieChartRef.value) {
pieChartInstance.dispose()
pieChartInstance = null
}
if (!pieChartInstance && existingInstance) {
pieChartInstance = existingInstance
}
if (!pieChartInstance) {
pieChartInstance = echarts.init(pieChartRef.value)
}
// 使用 Top N + Other 的数据逻辑,确保图表不会太拥挤
const list = [...expenseCategoriesView.value]
let chartData = []
// 按照金额排序
list.sort((a, b) => b.amount - a.amount)
const MAX_SLICES = 8 // 最大显示扇区数,其余合并为"其他"
if (list.length > MAX_SLICES) {
const topList = list.slice(0, MAX_SLICES - 1)
const otherList = list.slice(MAX_SLICES - 1)
const otherAmount = otherList.reduce((sum, item) => sum + item.amount, 0)
chartData = topList.map((item, index) => ({
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: props.colors[index % props.colors.length] }
}))
chartData.push({
value: otherAmount,
name: '其他',
itemStyle: { color: getCssVar('--van-gray-6') } // 使用灰色表示其他
})
} else {
chartData = list.map((item, index) => ({
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: props.colors[index % props.colors.length] }
}))
}
const option = {
title: {
text: '¥' + formatMoney(props.totalExpense),
subtext: '总支出',
left: 'center',
top: 'center',
textStyle: {
color: getCssVar('--chart-text-muted'), // 适配深色模式
fontSize: 20,
fontWeight: 'bold'
},
subtextStyle: {
color: getCssVar('--chart-text-muted'),
fontSize: 13
}
},
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.name}: ¥${formatMoney(params.value)} (${params.percent.toFixed(1)}%)`
}
},
series: [
{
name: '支出分类',
type: 'pie',
radius: ['50%', '80%'],
avoidLabelOverlap: true,
minAngle: 5, // 最小扇区角度,防止扇区太小看不见
itemStyle: {
borderRadius: 5,
borderColor: getCssVar('--van-background-2'),
borderWidth: 2
},
label: {
show: false
},
labelLine: {
show: false
},
data: chartData
}
]
}
pieChartInstance.setOption(option)
}
// 监听数据变化重新渲染图表
watch(() => [props.categories, props.totalExpense, props.colors], () => {
nextTick(() => {
renderPieChart()
})
}, { deep: true, immediate: true })
// 组件销毁时清理图表实例
onBeforeUnmount(() => {
if (pieChartInstance && !pieChartInstance.isDisposed()) {
pieChartInstance.dispose()
}
})
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
// 通用卡片样式
.common-card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
/* 环形图 */
.chart-container {
padding: 0;
}
.ring-chart {
position: relative;
width: 100%;
height: 200px;
margin: 0 auto;
}
/* 分类列表 */
.category-list {
padding: 0;
}
.category-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--van-border-color);
transition: background-color 0.2s;
gap: 12px;
}
.category-item:last-child {
border-bottom: none;
}
.category-item.clickable {
cursor: pointer;
}
.category-item.clickable:active {
background-color: var(--van-background);
}
.category-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.category-name-with-count {
display: flex;
align-items: center;
gap: 8px;
}
.category-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.category-name {
font-size: 14px;
color: var(--van-text-color);
}
.category-count {
font-size: 12px;
color: var(--van-text-color-3);
}
.category-stats {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.category-arrow {
margin-left: 8px;
color: var(--van-text-color-3);
font-size: 16px;
flex-shrink: 0;
}
.expand-toggle {
display: flex;
justify-content: center;
align-items: center;
padding-top: 0;
color: var(--van-text-color-3);
font-size: 20px;
cursor: pointer;
}
.expand-toggle:active {
opacity: 0.7;
}
.category-amount {
font-size: 15px;
font-weight: 600;
color: var(--van-text-color);
}
.category-percent {
font-size: 12px;
color: var(--van-text-color-3);
background: var(--van-background);
padding: 2px 8px;
border-radius: 10px;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<div class="income-balance-card common-card">
<div class="stats-row">
<div class="stat-item">
<div class="stat-label">
本月收入
</div>
<div class="stat-amount income">
¥{{ formatMoney(income) }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">
结余
</div>
<div
class="stat-amount"
:class="balanceClass"
>
¥{{ formatMoney(balance) }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { formatMoney } from '@/utils/format'
const props = defineProps({
income: {
type: Number,
required: true
},
balance: {
type: Number,
required: true
}
})
const balanceClass = computed(() => ({
'positive': props.balance >= 0,
'negative': props.balance < 0
}))
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
.income-balance-card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow-sm);
}
.stats-row {
display: flex;
gap: var(--spacing-3xl);
}
.stat-item {
flex: 1;
.stat-label {
font-size: var(--font-base);
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
}
.stat-amount {
font-size: var(--font-xl);
font-weight: var(--font-semibold);
font-family: var(--font-display);
line-height: 1.2;
&.income {
color: var(--accent-success);
}
&.positive {
color: var(--accent-success);
}
&.negative {
color: var(--accent-danger);
}
}
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<CategoryCard
title="收入分类"
:categories="categories"
:type="1"
tag-type="success"
empty-description="本期暂无收入记录"
@category-click="handleCategoryClick"
/>
</template>
<script setup>
import CategoryCard from '@/components/Statistics/CategoryCard.vue'
defineProps({
categories: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['category-click'])
const handleCategoryClick = (classify, type) => {
emit('category-click', classify, type)
}
</script>

View File

@@ -0,0 +1,272 @@
<template>
<!-- 收支和不计收支并列显示 -->
<div class="side-by-side-cards">
<!-- 收入分类统计 -->
<div class="common-card half-card">
<div class="card-header">
<h3 class="card-title">
收入
<span
class="income-text"
style="font-size: 13px; margin-left: 4px"
>
¥{{ formatMoney(totalIncome) }}
</span>
</h3>
<van-tag
type="success"
size="medium"
>
{{ incomeCategories.length }}
</van-tag>
</div>
<div
v-if="incomeCategories.length > 0"
class="category-list"
>
<div
v-for="category in incomeCategories"
:key="category.classify"
class="category-item clickable"
@click="$emit('category-click', category.classify, 1)"
>
<div class="category-info">
<div class="category-color income-color" />
<span class="category-name text-ellipsis">{{ category.classify || '未分类' }}</span>
</div>
<div class="category-amount income-text">
¥{{ formatMoney(category.amount) }}
</div>
</div>
</div>
<ModernEmpty
v-else
type="finance"
theme="green"
title="暂无收入"
description="本期还没有收入记录"
size="small"
/>
</div>
<!-- 不计收支分类统计 -->
<div class="common-card half-card">
<div class="card-header">
<h3 class="card-title">
不计收支
</h3>
<van-tag
type="warning"
size="medium"
>
{{ noneCategories.length }}
</van-tag>
</div>
<div
v-if="noneCategories.length > 0"
class="category-list"
>
<div
v-for="category in noneCategories"
:key="category.classify"
class="category-item clickable"
@click="$emit('category-click', category.classify, 2)"
>
<div class="category-info">
<div class="category-color none-color" />
<span class="category-name text-ellipsis">{{ category.classify || '未分类' }}</span>
</div>
<div class="category-amount none-text">
¥{{ formatMoney(category.amount) }}
</div>
</div>
</div>
<ModernEmpty
v-else
type="inbox"
theme="gray"
title="暂无数据"
description="本期没有不计收支记录"
size="small"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import ModernEmpty from '@/components/ModernEmpty.vue'
const props = defineProps({
incomeCategories: {
type: Array,
default: () => []
},
noneCategories: {
type: Array,
default: () => []
},
totalIncome: {
type: Number,
default: 0
}
})
defineEmits(['category-click'])
// 格式化金额
const formatMoney = (value) => {
if (!value && value !== 0) {
return '0'
}
return Number(value)
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// 处理未分类排序
const incomeCategories = computed(() => {
const list = [...props.incomeCategories]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
const noneCategories = computed(() => {
const list = [...props.noneCategories]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
// 通用卡片样式
.common-card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
/* 并列显示卡片 */
.side-by-side-cards {
display: flex;
gap: 12px;
margin: 0 12px 16px;
}
.side-by-side-cards .common-card {
margin: 0;
flex: 1;
min-width: 0; /* 允许内部元素缩小 */
padding: 12px;
}
.card-header {
margin-bottom: 0;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
/* 分类列表 */
.category-list {
padding: 0;
}
.category-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--van-border-color);
transition: background-color 0.2s;
gap: 12px;
}
.category-item:last-child {
border-bottom: none;
}
.category-item.clickable {
cursor: pointer;
}
.category-item.clickable:active {
background-color: var(--van-background);
}
.category-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.category-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.category-name {
font-size: 14px;
color: var(--van-text-color);
}
.category-amount {
font-size: 15px;
font-weight: 600;
color: var(--van-text-color);
flex-shrink: 0;
}
.income-color {
background-color: var(--van-success-color);
}
.income-text {
color: var(--van-success-color);
}
/* 不计收支颜色 */
.none-color {
background-color: var(--van-gray-6);
}
.none-text {
color: var(--van-gray-6);
}
</style>

Some files were not shown because too many files have changed in this diff Show More