42 Commits

Author SHA1 Message Date
SunCheng
a2871cb775 v2 2026-02-04 14:33:30 +08:00
SunCheng
28c45e8e77 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 33s
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 1s
2026-02-04 14:33:08 +08:00
SunCheng
952c75bf08 1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 54s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-02-03 17:56:32 +08:00
SunCheng
488667bf9c fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 26s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 3s
2026-02-02 19:42:35 +08:00
SunCheng
534a726648 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
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-02 17:05:51 +08:00
SunCheng
338bac20ce todo
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 28s
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 2s
2026-02-02 16:59:24 +08:00
SunCheng
6abc5f8b6d tmp2
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 6m47s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-02-02 11:07:49 +08:00
SunCheng
460dcd17ef tmp 2026-02-02 11:07:34 +08:00
61916dc6da fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 31s
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 1s
2026-02-01 10:27:04 +08:00
SunCheng
704f58b1a1 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 24s
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 3s
2026-01-30 10:41:19 +08:00
SunCheng
d9703d31ae 1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 34s
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 1s
2026-01-28 19:32:11 +08:00
SunCheng
e93c3d6bae 测试覆盖率
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 27s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-28 17:00:58 +08:00
SunCheng
3ed9cf5ebd 重构: 将 LogCleanupService 转为 Quartz Job 服务
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
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 1s
- 创建 LogCleanupJob 替代 LogCleanupService (BackgroundService)
- 在 Expand.cs 中注册 LogCleanupJob (每天凌晨2点执行, 保留30天日志)
- 从 Program.cs 移除 LogCleanupService 的 HostedService 注册
- 删除 Service/LogCleanupService.cs
- 删除 Service/PeriodicBillBackgroundService.cs (已无用的重复服务)

所有后台任务现在统一通过 Quartz.NET 管理, 支持运行时控制
2026-01-28 11:19:23 +08:00
SunCheng
b71eadd4f9 重构账单查询sql
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 26s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-28 10:58:15 +08:00
SunCheng
5c9d7c5db1 样式修复
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
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-01-27 17:54:57 +08:00
SunCheng
9729ff2f82 1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 1m58s
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-01-27 17:06:35 +08:00
SunCheng
b78774bc39 优化待分类页面
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 1m55s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-01-27 16:46:16 +08:00
SunCheng
4aa7e82429 移除对账功能 后期从长计议
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 1m57s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-01-27 15:29:25 +08:00
bade93ad57 样式调整
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 24s
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 1s
2026-01-25 13:27:55 +08:00
47e1ed3891 1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 24s
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-01-25 13:22:51 +08:00
SunCheng
4ff99b62c8 优化
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 39s
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-01-23 17:14:41 +08:00
SunCheng
58627356f4 1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
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 1s
2026-01-22 21:27:56 +08:00
SunCheng
dcbde4db23 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 25s
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 2s
2026-01-22 21:03:00 +08:00
SunCheng
9e14849014 feat: 添加预算统计服务增强和日志系统改进
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 24s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
1. 新增 BudgetStatsService:将预算统计逻辑从 BudgetService 中提取为独立服务,支持月度和年度统计,包含归档数据支持和硬性预算调整算法
2. 日志系统增强:添加请求ID追踪功能,支持通过请求ID查询关联日志,新增类名筛选功能
3. 日志解析优化:修复类名解析逻辑,正确提取 SourceContext 中的类名信息
4. 代码清理:移除不需要的方法名相关代码,简化日志筛选逻辑
2026-01-22 19:07:10 +08:00
SunCheng
e2c0ab5389 构建优化 2026-01-22 13:28:24 +08:00
SunCheng
a06a75ee97 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 23s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-22 11:06:52 +08:00
SunCheng
937e8db776 fix bugs
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 28s
Docker Build & Deploy / Deploy to Production (push) Successful in 11s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-01-21 19:51:41 +08:00
SunCheng
64aa24c07b fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 5s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-01-21 18:52:31 +08:00
SunCheng
b2e903e968 1
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-01-21 16:09:38 +08:00
SunCheng
c2a27abcac 1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 23s
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-01-21 11:58:48 +08:00
SunCheng
a2bfff5790 1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
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-01-20 19:56:29 +08:00
SunCheng
66cfd71ef5 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
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 2s
2026-01-20 19:26:36 +08:00
SunCheng
aa0bdecc50 1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 50s
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 1s
2026-01-20 19:19:53 +08:00
SunCheng
0708f833c8 样式调整
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 30s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-20 19:13:33 +08:00
SunCheng
0ffeb41605 重构存款预算
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 44s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 3s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-20 19:11:05 +08:00
SunCheng
44d9fbb0f6 todo
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 23s
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 1s
2026-01-19 13:39:59 +08:00
SunCheng
32efbb0324 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 1s
2026-01-18 22:35:03 +08:00
SunCheng
1711ee410d fix
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 50s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-18 22:32:11 +08:00
SunCheng
b0d632dcb7 fix
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 1m19s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-18 22:29:00 +08:00
SunCheng
435efbcb90 大量的代码格式化
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 38s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-18 22:25:59 +08:00
SunCheng
9611ff2088 添加简单测试 2026-01-18 22:17:32 +08:00
SunCheng
4e2bf0da6c 大量后端代码格式化 2026-01-18 22:04:56 +08:00
170 changed files with 28067 additions and 3916 deletions

View File

@@ -13,13 +13,64 @@ jobs:
name: Build Docker Image name: Build Docker Image
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# ✅ 使用 Gitea 兼容的代码检出方式 # 网络连接测试
- name: Test network connectivity
run: |
echo "Testing network connectivity to Gitea server..."
MAX_RETRIES=5
RETRY_DELAY=10
for i in $(seq 1 $MAX_RETRIES); do
echo "Network test attempt $i/$MAX_RETRIES"
if curl -s --connect-timeout 10 -f http://192.168.31.14:14200 > /dev/null; then
echo "✅ Gitea server is reachable"
exit 0
else
echo "❌ Network test failed, waiting $RETRY_DELAY seconds..."
sleep $RETRY_DELAY
fi
done
echo "❌ All network tests failed"
exit 1
- name: Checkout code - name: Checkout code
uses: https://gitea.com/actions/checkout@v3 uses: https://gitea.com/actions/checkout@v3
with: # 添加重试策略
gitea-server: http://192.168.31.14:14200 continue-on-error: true
token: ${{ secrets.GITEA_TOKEN }}
ref: ${{ gitea.ref }} # 必须传递 Gitea 的 ref 参数 # 手动重试逻辑
- name: Retry checkout if failed
if: steps.checkout.outcome == 'failure'
run: |
echo "First checkout attempt failed, retrying..."
MAX_RETRIES=3
RETRY_DELAY=15
for i in $(seq 1 $MAX_RETRIES); do
echo "Retry attempt $i/$MAX_RETRIES"
# 清理可能的部分检出
rm -rf .git || true
git clean -fdx || true
# 使用git命令直接检出
git init
git remote add origin http://192.168.31.14:14200/${{ gitea.repository }}
git config http.extraHeader "Authorization: Bearer ${{ secrets.GITEA_TOKEN }}"
if git fetch --depth=1 origin "${{ gitea.ref }}"; then
git checkout FETCH_HEAD
echo "Checkout successful on retry $i"
exit 0
fi
echo "Retry $i failed, waiting $RETRY_DELAY seconds..."
sleep $RETRY_DELAY
done
echo "All checkout attempts failed"
exit 1
- name: Cleanup old containers - name: Cleanup old containers
run: | run: |
@@ -28,7 +79,7 @@ jobs:
- name: Build new image - name: Build new image
run: | run: |
RETRIES=3 RETRIES=20
DELAY=10 DELAY=10
count=0 count=0
until docker build -t $IMAGE_NAME .; do until docker build -t $IMAGE_NAME .; do

View File

@@ -0,0 +1,201 @@
---
name: bug-fix
description: Bug诊断与修复技能 - 强调交互式确认和影响分析
tags:
- bug-fix
- debugging
- troubleshooting
- interactive
version: 1.0.1
---
# Bug修复技能
## 技能概述
专门用于诊断和修复项目中的bug强调谨慎的分析流程和充分的交互式确认确保修复的准确性和完整性避免引入新的问题或破坏现有功能。
## ⚠️ 强制交互规则MUST FOLLOW
**遇到需要用户确认的情况时,必须立即调用 `question` 工具:**
**禁止**"我需要向用户确认..."、"请用户回答..."、"在Plan模式下建议先询问..."
**必须**:直接调用工具,不要描述或延迟
**调用格式**
```javascript
question({
header: "问题确认",
questions: [{
question: "具体触发场景是什么?",
options: ["新增时", "修改时", "批量导入时", "定时任务时", "其他"]
}]
})
```
**规则**
- 每次最多 **3个问题**
- 每个问题 **3-6个选项**(穷举常见情况 + "其他"兜底)
- 用户通过**上下键导航**选择
- 适用于**所有模式**Build/Plan
## 执行原则
### 1. 充分理解问题(必要时交互确认)
**触发条件**
- 用户对bug的描述含糊不清
- 问题复现步骤不完整
- 预期行为与实际行为表述存在歧义
- 涉及多个可能的问题根因
**执行策略**
-**立即调用 `question` 工具**(不要描述,直接执行)
-**暂停其他操作**,不要基于假设进行修复
- ✅ 澄清:错误现象、触发条件、预期行为、是否有日志
### 2. 风险评估与影响分析(必要时交互确认)
**触发条件**
- 发现潜在的边界情况用户未提及
- 代码修改可能影响其他功能模块
- 存在多种修复方案,各有利弊
- 发现可能的性能、安全或兼容性隐患
**执行策略**
- ✅ 代码分析后,**不要直接修改代码**
- ✅ 报告潜在问题:影响范围、边界情况、测试场景、数据迁移需求
- ✅ **使用 `question` 工具**让用户选择方案或确认风险
### 3. 关联代码检查(必要时交互确认)
**触发条件**
- 发现多个位置存在相似的代码逻辑
- 修复需要同步更新多个文件
- 存在可能依赖该bug行为的代码反模式
- 发现测试用例可能基于错误行为编写
**执行策略**
- ✅ 使用代码搜索工具查找相似逻辑和调用链
- ✅ 报告关联代码:是否需要同步修复、依赖关系、测试更新
- ✅ **使用 `question` 工具**让用户确认修复范围
## 修复流程
### 阶段1: 问题诊断
1. 阅读用户的bug描述
2. 定位相关代码文件(使用 semantic_search, grep_search
3. 分析代码逻辑和调用链
4. **触发点1**: 如有不明确之处 → **立即调用 `question` 工具**(不要描述计划)
### 阶段2: 根因分析
1. 确定bug的根本原因
2. 识别影响范围和边界情况
3. **触发点2**: 发现用户未考虑的问题 → **立即调用 `question` 工具**
### 阶段3: 方案设计
1. 设计修复方案
2. 评估方案的影响和风险
3. 查找相似代码和依赖关系
4. **触发点3**: 可能影响其他逻辑 → **立即调用 `question` 工具**
### 阶段4: 实施修复
1. 获得用户最终确认后执行代码修改
2. 使用 `multi_replace_string_in_file` 批量修改
3. 确保修改的原子性和一致性
### 阶段5: 验证建议
提示用户执行:单元测试(`dotnet test`)、手动测试场景、回归测试
## 反模式与成功标准
❌ **不要**在未明确理解问题的情况下猜测修复
❌ **不要**在未评估影响范围的情况下直接修改代码
❌ **不要**假设用户已考虑所有边界情况
❌ **不要**在发现关联逻辑时仅修复单个位置
❌ **不要**跳过风险通知直接实施有争议的修复方案
❌ **不要**修改代码后不提供验证建议
❌ **不要**描述"我会询问用户..."而不调用 `question` 工具
❌ **不要**列出问题清单让用户自行回答
❌ **不要**在Plan模式下说"如果用户确认..."而不执行工具调用
## 成功标准
✅ 用户明确确认问题理解无误(通过 `question` 工具获取)
✅ 所有潜在风险和影响已向用户说明(通过 `question` 工具确认)
✅ 修复方案获得用户明确同意(通过 `question` 工具选择)
✅ 相似逻辑和关联代码已同步处理(如适用)
✅ 提供了完整的验证和测试建议
## 示例工作流
```
用户: "策略计算结果不对,加价逻辑有问题"
Agent:
[阶段1 - 问题诊断]
我需要确认几个细节:
1. 具体是哪种类型的策略计算不对?(机票、动态加价、固定加价?)
2. 能否提供一个具体的测试用例输入和错误输出?
3. 预期的正确输出是什么?
用户: "动态加价百分比计算错误应该是基准价×10%但实际是×100%"
Agent:
[阶段2 - 根因分析]
已定位到问题:
- 位置: src/Domain.Service/Services/AgentService.cs:234
- 原因: 百分比未除以100
[阶段3 - 方案设计]
⚠️ 发现以下需要确认的点:
1. **关联代码**: 在 PolicyService.cs 中发现相似的百分比计算3处
是否也存在相同问题?
2. **单元测试**: UnitTestPolicy.cs:67 的断言值为100%,可能基于错误行为,
修复后需要更新测试
3. **前端影响**: 如果历史数据按错误逻辑存储,前端展示需要迁移脚本吗?
请确认是否需要一并处理这些关联问题?
用户: "是的PolicyService 也需要修复,测试用例也要改,历史数据没有存储"
Agent:
[阶段4 - 实施修复]
好的,我将修复以下位置:
1. AgentService.cs:234
2. PolicyService.cs:156, 178, 201
3. UnitTestPolicy.cs:67
[执行代码修改...]
[阶段5 - 验证建议]
修复完成!建议验证:
1. 运行单元测试: dotnet test
2. 手动测试场景:
- 输入基准价1000加价10%期望结果1100
- 验证策略列表API返回正确百分比
3. 回归测试: 检查其他类型策略未受影响
```
## 适用场景
- ✅ 逻辑错误修复
- ✅ 空指针异常修复
- ✅ 数据计算错误
- ✅ API行为异常
- ✅ 性能问题修复
- ✅ 边界条件处理
## 相关技能
- `refactor`: 重构优化非bug修复
- `feature`: 新功能开发
- `test`: 测试用例编写
## 核心约束(必须遵守)
1. **禁止开放式提问** - 所有需要用户输入的场景,必须提供选项列表
2. 每次交互最多提出5个问题避免信息过载
3. 选项设计要穷举常见情况,并保留"其他"兜底选项

View File

@@ -0,0 +1,856 @@
---
name: pancli-design
description: 专业的设计技能,用于使用 pancli (pencil tools) 创建现代化、一致的 EmailBill 移动端 UI 设计
license: MIT
compatibility: Requires pencil_* tools (batch_design, batch_get, etc.)
metadata:
author: EmailBill Design Team
version: "2.0.0"
generatedBy: opencode
lastUpdated: "2026-02-03"
source: ".pans/v2.pen 日历设计 (亮色/暗色)"
---
# pancli-design - EmailBill UI 设计系统
> 专业的设计技能,用于使用 pancli (pencil tools) 创建现代化、一致的移动端 UI 设计。
## 何时使用此技能
**总是使用此技能当:**
- 使用 pancli 创建新的 UI 界面或组件
- 修改现有的 .pen 设计文件
- 处理亮色/暗色主题设计
- 为 EmailBill 项目设计移动端优先的界面
**触发条件:**
- 用户提到 "画设计图"、"设计"、"UI"、"界面"、"pancli"、".pen"
- 任务涉及 `pencil_*` 工具
- 创建视觉原型或模型
## 核心设计原则
### 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)` 用于突出表面
**避免 AI 设计痕迹:**
- 不要使用通用的 "Dashboard" 占位符文本
- 不要使用图库照片或 Lorem Ipsum
- 不要使用过饱和的主色 (#007AFF, 不是 #0088FF)
- 不要使用生硬的阴影或渐变
- 不要使用 Comic Sans, Papyrus 或装饰性字体
- 使用代码库中的真实中文业务术语
### 2. 统一色彩系统 (基于实际 v2.pen 日历设计)
**亮色主题:**
```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) 文本
### 3. 排版系统 (基于实际 v2.pen 设计)
**字体栈:**
```css
/* 标题与大标题 - 基于 v2.pen */
font-family: 'Bricolage Grotesque', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
/* 正文与界面 - 基于 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)
- 行高: 1.4-1.6 保证可读性
- 使用真实业务术语,避免 Lorem Ipsum
### 4. 组件库 (基于实际 v2.pen 设计)
**卡片 (基于 statsCard, tCard):**
```
统计卡片 (大卡片):
- 背景: #F6F7F8 (亮色), #18181B (暗色)
- 内边距: 20px
- 圆角: 20px
- 间距: 12px (元素之间)
- 布局: 垂直
交易卡片 (列表卡片):
- 背景: #F6F7F8 (亮色), #18181B (暗色)
- 内边距: 16px
- 圆角: 16px
- 间距: 14px (水平元素)
- 高度: 自适应内容
```
**按钮 (基于实际设计):**
```
图标按钮 (通知按钮):
- 尺寸: 44x44px
- 圆角: 22px (完全圆形)
- 背景: #F5F5F5 (亮色), #27272A (暗色)
- 图标大小: 20px
标签按钮:
- 内边距: 6px 10px / 6px 12px
- 圆角: 12px
- 字体: DM Sans 13px/500
- 颜色:
- 温暖色: #FFFBEB (亮色), #451A03 (暗色)
- 绿色: #F0FDF4 (亮色), #064E3B (暗色)
- 蓝色: #E0E7FF (亮色), #312E81 (暗色)
悬浮按钮 (FAB):
- 尺寸: 56x56px
- 圆角: 28px
- 背景: #3B82F6
- 描边: 4px 白色边框
- 阴影: 提升效果
```
**图标与文字:**
```
图标容器:
- 尺寸: 44x44px
- 圆角: 22px
- 背景: #FFFFFF (亮色), #27272A (暗色)
- 图标: 20px (lucide 字体)
- 颜色: #FF6B6B (星标), #FCD34D (咖啡)
章节标题:
- 字体: Bricolage Grotesque 18px/700
- 颜色: #1A1A1A (亮色), #F4F4F5 (暗色)
大数值:
- 字体: Bricolage Grotesque 32px/800
- 颜色: #1A1A1A (亮色), #F4F4F5 (暗色)
```
**布局模式 (基于 Calendar 结构):**
```
页面容器:
- 宽度: 402px (设计视口)
- 布局: 垂直
- 内边距: 24px (容器边距)
- 间距: 16px (章节之间)
头部区域:
- 内边距: 8px 24px
- 布局: 水平, 两端对齐
- 对齐项: 居中
内容区域:
- 内边距: 24px
- 间距: 12-16px
- 布局: 垂直
```
### 5. 布局模式
**页面结构 (Flex 容器):**
```
.page-container-flex:
- display: flex
- flex-direction: column
- height: 100%
- overflow: hidden
结构:
1. van-nav-bar (固定高度)
2. van-tabs 或 sticky-header
3. scroll-content (flex: 1, overflow-y: auto)
4. bottom-button 或 van-tabbar (固定)
```
**导航栏背景透明化 (一致性模式):**
```css
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
}
```
**重要:** 项目中所有视图都采用此模式,使导航栏背景透明,与页面背景融合。实现此效果时,请确保:
- 页面容器有明确的背景色 (亮色: #FFFFFF, 暗色: #09090B)
- 导航栏始终使用 `:deep(.van-nav-bar)` 选择器
- 必须添加 `!important` 覆盖 Vant 默认样式
-`<style scoped>` 块中添加此规则
**安全区域处理:**
```css
/* iPhone 刘海底部内边距 */
padding-bottom: env(safe-area-inset-bottom, 0px);
/* 状态栏顶部内边距 */
padding-top: max(0px, calc(env(safe-area-inset-top, 0px) * 0.75));
```
**固定元素:**
```css
.sticky-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--van-background-2);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
margin: 12px;
padding: 12px 16px;
}
```
### 6. 交互模式
**触摸反馈:**
- 激活状态: 点击时 scale(0.95)
- 涟漪效果: 使用 Vant 内置触摸反馈
- 悬停状态: 12% 透明度叠加 (网页端)
**加载状态:**
```
van-pull-refresh:
- 用于顶层可滚动内容
- 最小高度: calc(100vh - nav - tabbar)
van-loading:
- 容器内居中
- 尺寸: 内联 24px, 页面 32px
```
**空状态:**
```
van-empty:
- 图标: 60px 大小
- 描述: 14px, var(--van-text-color-2)
- 内边距: 垂直 48px
```
**悬浮操作:**
```
van-floating-bubble:
- 图标大小: 24px
- 位置: 右下角, 距底部 100px (避开 tabbar)
- 磁吸: 贴靠 x 轴边缘
```
### 7. 数据可视化
**预算进度条:**
```
渐变逻辑:
支出 (0% → 100%):
- 0%: #40a9ff (安全蓝)
- 40%: #36cfc9 (青色过渡)
- 70%: #faad14 (警告黄)
- 100%: #ff4d4f (危险红)
收入 (0% → 100%):
- 0%: #f5222d (深红 - 未开始)
- 45%: #ffcccc (浅红)
- 50%: #f0f2f5 (中性灰)
- 55%: #bae7ff (浅蓝)
- 100%: #1890ff (深蓝 - 达成)
```
**金额显示:**
```css
.amount {
font-family: 'DIN Alternate', system-ui;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.expense { color: var(--van-danger-color); }
.income { color: var(--van-success-color); }
```
**图表 (如果使用):**
- 折线图: 2px 笔画, 圆角连接
- 柱状图: 8px 圆角, 4px 间距
- 颜色: 使用语义色阶
- 网格线: 1px, 8% 透明度
### 8. 主题切换 (亮色/暗色)
**实现策略:**
```javascript
// 自动检测系统偏好
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
theme.value = isDark ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', theme.value)
```
**设计文件要求:**
- **必须同时创建亮色和暗色变体**
- 使用 Vant 的主题变量 (自动切换)
- 测试对比度: WCAG AA 最低标准 (文本 4.5:1)
- 暗色模式适配:
- 减少卡片阴影至 0 2px 8px rgba(0,0,0,0.24)
- 增加边框对比度
- 白色文本柔化至 #e5e5e5
**pancli 工作流:**
```
1. 先创建亮色主题设计
2. 复制帧用于暗色模式
3. 使用 replace_all_matching_properties 批量更新:
- 背景颜色
- 文本颜色
- 边框颜色
4. 手动调整阴影和叠加
5. 命名帧: "[屏幕名称] - Light" / "[屏幕名称] - Dark"
```
### 9. 命名约定
**帧名称:**
```
格式: [模块] - [屏幕] - [变体]
示例:
✅ Budget - List View - Light
✅ Budget - Edit Dialog - Dark
✅ Transaction - Card Component
✅ Statistics - Chart Section
❌ Screen1
❌ Frame_Copy_2
❌ New Design
```
**组件层级:**
```
可复用组件:
- 前缀 "Component/"
- 示例: "Component/BudgetCard"
屏幕:
- 按模块分组
- 示例: "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; }`)
**无障碍:**
- [ ] 正文对比度 ≥ 4.5:1
- [ ] 大文本对比度 ≥ 3:1 (18px+)
- [ ] 触摸目标清晰分离 (最小 8px 间距)
- [ ] 颜色不是信息的唯一指示器
## PANCLI 工作流程
### 阶段 1: 设置与风格选择
```typescript
// 1. 获取编辑器状态
pencil_get_editor_state(include_schema: true)
// 2. 获取设计指南
pencil_get_guidelines(topic: "landing-page") // 或 "design-system"
// 3. 选择合适的风格指南
pencil_get_style_guide_tags() // 获取可用标签
// 4. 使用标签获取风格指南
pencil_get_style_guide(tags: [
"mobile", // 必需
"webapp", // 类应用界面
"modern", // 简洁, 现代
"minimal", // 避免杂乱
"professional", // 商业环境
"blue", // 主色提示
"fintech" // 如果可用
])
```
### 阶段 2: 创建亮色主题设计
```typescript
// 5. 读取现有组件 (如果有)
pencil_batch_get(
filePath: "designs/emailbill.pen",
patterns: [{ reusable: true }],
readDepth: 2
)
// 6. 创建亮色主题屏幕
pencil_batch_design(
filePath: "designs/emailbill.pen",
operations: `
screen=I(document, {
type: "frame",
name: "Budget - List View - Light",
width: 375,
height: 812,
fill: "#FFFFFF",
layout: "vertical",
placeholder: true
})
navbar=I(screen, {
type: "frame",
name: "Navbar",
width: "fill_container",
height: 44,
fill: "transparent",
layout: "horizontal",
padding: [12, 16, 12, 16]
})
title=I(navbar, {
type: "text",
content: "预算管理",
fontSize: 16,
fontWeight: "600",
textColor: "#1A1A1A"
})
// ... 更多操作
`
)
```
### 阶段 3: 创建暗色主题变体
```typescript
// 7. 复制亮色主题帧
pencil_batch_design(
operations: `
darkScreen=C("light-screen-id", document, {
name: "Budget - List View - Dark",
positionDirection: "right",
positionPadding: 48
})
`
)
// 8. 批量替换暗色主题颜色
pencil_replace_all_matching_properties(
parents: ["dark-screen-id"],
properties: {
fillColor: [
{ from: "#FFFFFF", to: "#09090B" }, // 页面背景
{ from: "#F6F7F8", to: "#18181B" }, // 卡片背景
{ from: "#F5F5F5", to: "#27272A" } // 边框
],
textColor: [
{ from: "#1A1A1A", to: "#F4F4F5" }, // 主文本
{ from: "#6B7280", to: "#A1A1AA" }, // 次要
{ from: "#9CA3AF", to: "#71717A" } // 三级
]
}
)
// 9. 手动调整暗色模式阴影 (如需要)
pencil_batch_design(
operations: `
U("dark-card-id", {
shadow: {
x: 0,
y: 2,
blur: 8,
color: "rgba(0,0,0,0.24)"
}
})
`
)
```
### 阶段 4: 验证
```typescript
// 10. 对两种主题截图
pencil_get_screenshot(nodeId: "light-screen-id")
pencil_get_screenshot(nodeId: "dark-screen-id")
// 11. 检查布局问题
pencil_snapshot_layout(
parentId: "light-screen-id",
problemsOnly: true
)
// 12. 验证所有唯一属性
pencil_search_all_unique_properties(
parents: ["light-screen-id"],
properties: ["fillColor", "textColor", "fontSize"]
)
```
## 代码库实际示例
### 示例 1: 预算卡片组件
```
组件结构:
BudgetCard (375x120px)
├─ CardBackground (#ffffff, 16px 圆角, 阴影)
├─ HeaderRow (水平布局)
│ ├─ CategoryName (16px, 600 粗细)
│ └─ PeriodLabel (12px, 次要颜色)
├─ ProgressBar (基于比例渐变)
│ └─ ProgressFill (高度: 8px, 圆角: 4px)
├─ AmountRow (水平布局, 两端对齐)
│ ├─ CurrentAmount (DIN, 18px, 危险色)
│ ├─ LimitAmount (DIN, 14px, 次要)
│ └─ RemainingAmount (DIN, 14px, 成功色)
└─ FooterActions (可选, 储蓄按钮)
```
**pancli 实现:**
```typescript
card=I(parent, {
type: "frame",
name: "BudgetCard",
width: "fill_container",
height: 120,
fill: "#ffffff",
cornerRadius: [16, 16, 16, 16],
shadow: { x: 0, y: 2, blur: 12, color: "rgba(0,0,0,0.08)" },
stroke: { color: "#ebedf0", thickness: 1 },
padding: [16, 16, 16, 16],
layout: "vertical",
gap: 12,
placeholder: true
})
header=I(card, {
type: "frame",
layout: "horizontal",
width: "fill_container",
height: "hug_contents"
})
categoryName=I(header, {
type: "text",
content: "日常开销",
fontSize: 16,
fontWeight: "600",
textColor: "#323233"
})
// 带渐变的进度条
progressBar=I(card, {
type: "frame",
width: "fill_container",
height: 8,
fill: "#f0f0f0",
cornerRadius: [4, 4, 4, 4]
})
progressFill=I(progressBar, {
type: "frame",
width: "75%", // 75% 进度示例
height: 8,
fill: "linear-gradient(90deg, #40a9ff 0%, #faad14 100%)",
cornerRadius: [4, 4, 4, 4]
})
```
### 示例 2: 带日期选择器的固定头部
```
固定头部模式 (来自 BudgetView):
├─ 位置: sticky, top: 0
├─ 背景: var(--van-background-2)
├─ 圆角: 12px
├─ 阴影: 0 2px 8px rgba(0,0,0,0.04)
├─ 内边距: 12px 16px
├─ 内容: "2024年1月" + 下拉箭头图标
```
### 示例 3: 滑动删除列表项
```
van-swipe-cell 模式:
├─ 内容: BudgetCard 组件
├─ 右侧操作: 删除按钮
│ ├─ 宽度: 60px
│ ├─ 背景: var(--van-danger-color)
│ ├─ 文本: "删除"
│ └─ 全高 (100%)
```
## 避免的反模式
**❌ 不要这样做:**
```
// 通用 AI 生成内容
title=I(navbar, {
type: "text",
content: "Dashboard", // ❌ 使用 "预算管理" 代替
fontSize: 20, // ❌ 按字号比例使用 16px
fontWeight: "bold" // ❌ 使用数字值 600
})
// 不一致的间距
card=I(parent, {
padding: [15, 13, 17, 14] // ❌ 使用 8px 网格: [16, 16, 16, 16]
})
// 硬编码颜色而非语义
amount=I(card, {
textColor: "#ff0000" // ❌ 使用 var(--van-danger-color) 或 "#ee0a24"
})
// 缺少暗色模式
// ❌ 只创建亮色主题没有暗色变体
// 糟糕的命名
frame=I(document, {
name: "Frame_123" // ❌ 使用 "Budget - List View - Light"
})
```
**✅ 应该这样做:**
```typescript
// 真实业务术语
title=I(navbar, {
type: "text",
content: "预算管理",
fontSize: 16,
fontWeight: "600",
textColor: "#323233"
})
// 一致的 8px 网格间距
card=I(parent, {
padding: [16, 16, 16, 16],
gap: 12
})
// 语义颜色变量
amount=I(card, {
textColor: "#ee0a24", // 一致的危险色
fontFamily: "DIN Alternate"
})
// 总是创建两种主题
lightScreen=I(document, { name: "Budget - List - Light" })
darkScreen=C(lightScreen, document, {
name: "Budget - List - Dark",
positionDirection: "right"
})
// 清晰的描述性名称
card=I(parent, {
name: "BudgetCard",
reusable: true
})
```
## 委派与任务管理
**使用此技能时:**
```typescript
// 委派设计任务时加载此技能
delegate_task(
category: "visual-engineering",
load_skills: ["pancli-design", "frontend-ui-ux"],
description: "创建预算列表屏幕设计",
prompt: `
任务: 为 EmailBill 应用创建移动端预算列表屏幕设计
预期结果:
- 375x812px 亮色主题设计
- 暗色主题变体 (复制并适配)
- 可复用的 BudgetCard 组件
- 两种主题的截图验证
必需工具:
- pencil_get_style_guide_tags
- pencil_get_style_guide
- pencil_batch_design
- pencil_batch_get
- pencil_replace_all_matching_properties
- pencil_get_screenshot
必须做:
- 严格遵循 pancli-design 技能指南
- 使用真实中文业务术语 (预算, 账单, 分类)
- 创建亮色和暗色两种主题
- 使用 8px 网格间距系统
- 遵循 Vant UI 组件模式
- 使用 模块-屏幕-变体 格式命名帧
- 使用语义颜色变量
- 数字显示应用 DIN Alternate
- 导航栏背景必须设置为透明 (:deep(.van-nav-bar) { background: transparent !important; })
- 截图验证
不得做:
- 使用 Lorem Ipsum 或占位文本
- 只创建亮色主题没有暗色变体
- 使用任意间距 (必须遵循 8px 网格)
- 硬编码颜色 (使用语义变量)
- 使用通用 "Dashboard" 标签
- 跳过截图验证
- 创建名为 "Frame_1", "Copy" 等的帧
上下文:
- 移动视口: 375px 宽度
- 设计系统: 基于 Vant UI
- 配色方案: #1989fa 主色, #ee0a24 危险, #07c160 成功
- 字体: 中文系统默认, 数字 DIN Alternate
- designs/emailbill.pen 中的现有组件 (用 batch_get 检查)
`,
run_in_background: false
)
```
## 快速参考 (基于实际 v2.pen 设计)
**颜色面板:**
| 名称 | 亮色 | 暗色 | 用途 |
|------|------|------|------|
| 页面背景 | #FFFFFF | #09090B | 页面背景 |
| 卡片背景 | #F6F7F8 | #18181B | 卡片表面 |
| 强调背景 | #F5F5F5 | #27272A | 按钮, 图标容器 |
| 主文本 | #1A1A1A | #F4F4F5 | 主要文本 |
| 次要文本 | #6B7280 | #A1A1AA | 次要文本 |
| 三级文本 | #9CA3AF | #71717A | 三级文本 |
| 主色 | #3B82F6 | #3B82F6 | 操作, FAB |
| 红色 | #FF6B6B | #FF6B6B | 支出, 警告 |
| 黄色 | #FCD34D | #FCD34D | 警告 |
| 绿色 | #F0FDF4 | #064E3B | 收入标签 |
| 蓝色 | #E0E7FF | #312E81 | 信息标签 |
**排版 (实际字体):**
| 用途 | 字体 | 大小 | 粗细 |
|------|------|------|------|
| 大数值 | Bricolage Grotesque | 32px | 800 |
| 页面标题 | DM Sans | 24px | 500 |
| 章节标题 | Bricolage Grotesque | 18px | 700 |
| 正文 | DM Sans | 15px | 600 |
| 说明 | 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 (设计宽度), 移动端优先
---
**版本:** 2.0.0 (使用 v2.pen 实际设计值更新)
**最后更新:** 2026-02-03
**来源:** .pans/v2.pen 日历 (亮色/暗色)
**维护者:** EmailBill 设计团队

File diff suppressed because it is too large Load Diff

9208
.pans/v2.pen Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,325 @@
## 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

@@ -0,0 +1,36 @@
# 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

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

View File

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

View File

@@ -0,0 +1,80 @@
# 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

@@ -0,0 +1,27 @@
# 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

@@ -0,0 +1,181 @@
# 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

218
AGENTS.md Normal file
View File

@@ -0,0 +1,218 @@
# PROJECT KNOWLEDGE BASE - EmailBill
**Generated:** 2026-01-28
**Commit:** 5c9d7c5
**Branch:** main
## OVERVIEW
Full-stack budget tracking app with .NET 10 backend and Vue 3 frontend.
## Project Structure
```
EmailBill/
├── Common/ # Shared utilities and abstractions
├── Entity/ # Database entities (FreeSql ORM)
├── Repository/ # Data access layer
├── Service/ # Business logic layer
├── WebApi/ # ASP.NET Core Web API
├── WebApi.Test/ # Backend tests (xUnit)
└── Web/ # Vue 3 frontend (Vite + Vant UI)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Entity definitions | Entity/ | BaseEntity pattern, FreeSql attributes |
| Data access | Repository/ | BaseRepository, GlobalUsings |
| Business logic | Service/ | Jobs, Email services, App settings |
| 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 |
## Build & Test Commands
### Backend (.NET 10)
```bash
# Build and run
dotnet build EmailBill.sln
dotnet run --project WebApi/WebApi.csproj
# Run all tests
dotnet test WebApi.Test/WebApi.Test.csproj
# Run single test class
dotnet test --filter "FullyQualifiedName~BudgetStatsTest"
# Run single test method
dotnet test --filter "FullyQualifiedName~BudgetStatsTest.GetCategoryStats_月度_Test"
# Clean
dotnet clean EmailBill.sln
```
### Frontend (Vue 3)
```bash
cd Web
# Setup and dev
pnpm install
pnpm dev
# Build and preview
pnpm build
pnpm preview
# Lint and format
pnpm lint # ESLint with auto-fix
pnpm format # Prettier formatting
```
## C# Code Style
**Namespaces & Imports:**
- File-scoped namespaces: `namespace Entity;`
- Global usings in `Common/GlobalUsings.cs`
- Sort using statements alphabetically
**Naming:**
- Classes/Methods: `PascalCase`
- Interfaces: `IPascalCase`
- Private fields: `_camelCase`
- Parameters/locals: `camelCase`
**Entities:**
- Inherit from `BaseEntity`
- Use `[Column]` attributes for FreeSql ORM
- IDs via Snowflake: `YitIdHelper.NextId()`
- Use XML docs (`///`) for public APIs
- **Chinese comments for business logic** (per `.github/csharpe.prompt.md`)
**Best Practices:**
- Use modern C# syntax (records, pattern matching, nullable types)
- Use `IDateTimeProvider` instead of `DateTime.Now` for testability
- Avoid deep nesting, keep code flat and readable
- Reuse utilities from `Common` project
**Example:**
```csharp
namespace Entity;
/// <summary>
/// 实体基类
/// </summary>
public abstract class BaseEntity
{
[Column(IsPrimary = true)]
public long Id { get; set; } = YitIdHelper.NextId();
public DateTime CreateTime { get; set; } = DateTime.Now;
}
```
## Vue/TypeScript Style
**Component Structure:**
```vue
<template>
<van-config-provider :theme="theme">
<div class="component-name">
<!-- Content -->
</div>
</van-config-provider>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMessageStore } from '@/stores/message'
const messageStore = useMessageStore()
</script>
<style scoped lang="scss">
.component-name {
padding: 16px;
}
</style>
```
**Rules:**
- Composition API with `<script setup lang="ts">`
- Import order: Vue APIs → external libs → internal modules
- Use `@/` alias for absolute imports, avoid `../../../`
- Vant UI components: `<van-*>`
- Pinia for state, Vue Router for navigation
- SCSS with BEM naming, mobile-first design
**ESLint Rules (see `Web/eslint.config.js`):**
- 2-space indentation
- Single quotes, no semicolons
- `const` over `let`, no `var`
- Always use `===` (strict equality)
- `space-before-function-paren: 'always'`
- Max 1 empty line between blocks
- Vue: multi-word component names disabled
**Prettier Rules (see `Web/.prettierrc.json`):**
- Single quotes, no semicolons
- Trailing commas: none
- Print width: 100 chars
## Testing
**Backend (xUnit + NSubstitute + FluentAssertions):**
```csharp
public class BudgetStatsTest : BaseTest
{
private readonly IBudgetRepository _repo = Substitute.For<IBudgetRepository>();
[Fact]
public async Task GetCategoryStats_月度_Test()
{
// Arrange
_repo.GetAllAsync().Returns(testData);
// Act
var result = await _service.GetCategoryStatsAsync(category, date);
// Assert
result.Month.Limit.Should().Be(2500);
}
}
```
- Arrange-Act-Assert pattern
- Constructor injection for dependencies
- Use Chinese test method names for domain clarity
**Frontend:**
- Vue Test Utils for components
- axios-mock-adapter for API mocking
## Development Workflow
1. **Before committing backend:** `dotnet test`
2. **Before committing frontend:** `pnpm lint && pnpm build`
3. **Database migrations:** Use FreeSql (check `Repository/`)
4. **API docs:** Scalar OpenAPI viewer
## Environment
**Required:**
- .NET 10 SDK
- Node.js 20.19+ or 22.12+
- pnpm
**Database:** SQLite (embedded)
**Config:**
- Backend: `appsettings.json`
- Frontend: `.env.development` / `.env.production`
## Critical Guidelines (from `.github/csharpe.prompt.md`)
- 优先使用新C#语法 (Use modern C# syntax)
- 优先使用中文注释 (Prefer Chinese comments for business logic)
- 优先复用已有方法 (Reuse existing methods)
- 不要深嵌套代码 (Avoid deep nesting)
- 保持代码简洁易读 (Keep code clean and readable)

View File

@@ -0,0 +1,604 @@
# CalendarV2 页面功能验证报告
**验证时间**: 2026-02-03
**验证地址**: http://localhost:5173/calendar-v2
**状态**: ⚠️ 需要人工验证(自动化工具安装失败)
## 执行摘要
由于网络问题无法安装 Playwright/Puppeteer 浏览器驱动,我通过**源代码分析**和**架构审查**完成了验证准备工作。以下是基于代码分析的验证清单和手动验证指南。
---
## 📋 验证清单(基于代码分析)
### ✅ 1. 页面路由配置
**状态**: 已验证
- **路由路径**: `/calendar-v2`
- **组件文件**: `Web/src/views/calendarV2/Calendar.vue`
- **权限要求**: `requiresAuth: true`
- **Keep-alive**: 支持(组件名称: `CalendarV2`
### ✅ 2. 组件架构
**状态**: 已验证
CalendarV2 采用模块化设计,由三个独立子模块组成:
1. **CalendarModule** (`modules/Calendar.vue`)
- 负责日历网格显示
- 独立调用 API: `getDailyStatistics``getBudgetList`
- 支持触摸滑动切换月份
- 显示每日金额和预算超支标记
2. **StatsModule** (`modules/Stats.vue`)
- 显示选中日期的统计信息
- 独立调用 API需要确认具体实现
- 显示当日支出/收入金额
3. **TransactionListModule** (`modules/TransactionList.vue`)
- 显示选中日期的交易记录列表
- 独立调用 API需要确认具体实现
- 支持空状态显示
- 包含 Smart 按钮跳转到智能分类
**关键架构特性**:
- ✅ 各模块**独立查询数据**(不通过 props 传递数据)
- ✅ 通过 `selectedDate` prop 触发子模块重新查询
- ✅ 支持下拉刷新(`van-pull-refresh`
- ✅ 全局事件监听(`transactions-changed`)自动刷新
---
## 🔍 功能点验证(需要手动确认)
### 1. 日历显示功能
#### 1.1 日历网格
**代码位置**: `calendarV2/modules/Calendar.vue` L10-L62
- ✅ 7列网格布局星期一到星期日
- ✅ 星期标题显示:`['一', '二', '三', '四', '五', '六', '日']`
- ✅ 月份标题:格式 `${year}年${month}月`L99-103
- ✅ 今天日期高亮CSS class `day-today`
- ✅ 有数据的日期显示金额:`day-amount`
**需要验证**:
- [ ] 网格是否正确渲染
- [ ] 星期标题是否对齐
- [ ] 月份标题是否显示在头部
- [ ] 今天日期是否有特殊样式
- [ ] 有交易的日期是否显示金额
#### 1.2 数据加载
**API 调用**: `getDailyStatistics({ year, month })` (L92)
- ✅ 获取月度每日统计
- ✅ 构建 `statsMap`(日期 -> {count, expense, income, income}
- ✅ 获取预算数据 `dailyBudget`
- ✅ 计算是否超支:`day.isOverLimit`
**需要验证**:
- [ ] 页面加载时是否调用 API
- [ ] 控制台 Network 标签查看请求:`/TransactionRecord/GetDailyStatistics?year=2026&month=2`
- [ ] 响应数据格式是否正确
- [ ] 日期金额是否正确显示
### 2. 日期选择功能
**代码位置**: `Calendar.vue` L114-136
- ✅ 点击日期单元格触发 `onDayClick`
- ✅ 更新 `selectedDate`
- ✅ 如果点击其他月份日期,自动切换月份
- ✅ 选中日期添加 CSS class `day-selected`
**需要验证**:
- [ ] 点击日期后是否有选中样式(背景色变化)
- [ ] 下方统计卡片是否显示该日期的标题(如"2026年2月3日"
- [ ] 统计卡片是否显示当日支出和收入
- [ ] 交易列表是否刷新
### 3. 月份切换功能
#### 3.1 按钮导航
**代码位置**: `Calendar.vue` L5-23, L162-198
- ✅ 左箭头按钮:`@click="changeMonth(-1)"`
- ✅ 右箭头按钮:`@click="changeMonth(1)"`
- ✅ 防止切换到未来月份L174-177
- ✅ 滑动动画:`slideDirection` + Transition
**需要验证**:
- [ ] 点击左箭头,切换到上一月
- [ ] 点击右箭头,切换到下一月
- [ ] 切换到当前月后,右箭头是否禁用并提示"已经是最后一个月了"
- [ ] 月份标题是否更新
- [ ] 是否有滑动动画效果
#### 3.2 触摸滑动
**代码位置**: `Calendar.vue` L200-252
-`onTouchStart`/`onTouchMove`/`onTouchEnd`
- ✅ 最小滑动距离50px
- ✅ 向左滑动 → 下一月
- ✅ 向右滑动 → 上一月
- ✅ 阻止垂直滚动冲突L223-228
**需要验证**:
- [ ] 在日历区域向左滑动,是否切换到下一月
- [ ] 在日历区域向右滑动,是否切换到上一月
- [ ] 滑动距离太短是否不触发切换
- [ ] 垂直滚动是否不受影响
### 4. 统计模块 (StatsModule)
**代码位置**: `calendarV2/modules/Stats.vue`(需要读取文件确认)
**Props**: `selectedDate`
**需要验证**:
- [ ] 选中日期后,统计卡片是否显示
- [ ] 显示格式:`2026年X月X日`
- [ ] 显示当日支出金额
- [ ] 显示当日收入金额
- [ ] 数据是否来自独立 API 调用(不是 props 传递)
### 5. 交易列表模块 (TransactionListModule)
**代码位置**: `calendarV2/modules/TransactionList.vue`(需要读取文件确认)
**Props**: `selectedDate`
**Events**: `@transaction-click`, `@smart-click`
**需要验证**:
- [ ] 选中日期后,交易列表是否显示
- [ ] 如果有交易,是否显示交易卡片(名称、时间、金额、图标)
- [ ] 如果无交易,是否显示空状态提示
- [ ] 交易数量徽章是否显示("X Items"
- [ ] 点击交易卡片是否跳转到详情页
- [ ] 点击 Smart 按钮是否跳转到智能分类页面
### 6. 其他功能
#### 6.1 通知按钮
**代码位置**: `Calendar.vue` L24-30, L146-149
- ✅ 点击跳转到 `/message` 路由
**需要验证**:
- [ ] 通知图标bell是否显示在右上角
- [ ] 点击是否跳转到消息页面
#### 6.2 下拉刷新
**代码位置**: `Calendar.vue` L36-39, L261-275
- ✅ 使用 `van-pull-refresh` 组件
- ✅ 触发 `onRefresh` 方法
- ✅ 显示 Toast 提示
**需要验证**:
- [ ] 下拉页面是否触发刷新
- [ ] 刷新时是否显示加载动画
- [ ] 刷新后数据是否更新
- [ ] 是否显示"刷新成功"提示
#### 6.3 全局事件监听
**代码位置**: `Calendar.vue` L254-259, L277-281
- ✅ 监听 `transactions-changed` 事件
- ✅ 触发子组件刷新
**需要验证**:
- [ ] 从其他页面添加账单后返回,数据是否自动刷新
---
## 🔌 API 依赖验证
### 关键 API 端点
1. **获取每日统计**
```
GET /TransactionRecord/GetDailyStatistics?year=2026&month=2
```
- 返回格式: `{ success: true, data: [{ date: '2026-02-01', count: 5, expense: 1200, income: 3000 }] }`
2. **获取预算列表**
```
GET /Budget/List
```
- 用于计算每日预算和超支判断
3. **其他 API**(需要确认 StatsModule 和 TransactionListModule 的实现)
- 可能调用 `/TransactionRecord/GetList`
- 可能调用其他统计接口
**需要验证**:
- [ ] 浏览器开发者工具 Network 标签查看所有 API 请求
- [ ] 确认响应状态码为 200
- [ ] 确认响应数据格式正确
- [ ] 确认错误处理网络错误、API 错误)
---
## 🎯 手动验证步骤
### 步骤 1: 导航到页面
1. 打开浏览器访问 `http://localhost:5173`
2. 如果需要登录,输入凭据
3. 导航到 `/calendar-v2` 或在界面中找到 CalendarV2 入口
4. 确认页面加载成功
### 步骤 2: 基础显示验证
1. ✓ 检查日历网格是否显示7列
2. ✓ 检查星期标题(一、二、三、四、五、六、日)
3. ✓ 检查月份标题2026年2月
4. ✓ 检查今天日期是否高亮
5. ✓ 检查有交易的日期是否显示金额
### 步骤 3: 交互功能验证
1. ✓ 点击一个日期,检查:
- 日期是否被选中(背景变化)
- 下方是否显示统计卡片
- 统计卡片是否显示正确日期
- 交易列表是否刷新
2. ✓ 点击左箭头按钮,检查:
- 是否切换到上一月
- 月份标题是否更新
- 是否有动画效果
3. ✓ 点击右箭头按钮,检查:
- 是否切换到下一月
- 如果当前是本月,是否提示"已经是最后一个月了"
4. ✓ 在日历区域滑动,检查:
- 向左滑动是否切换到下一月
- 向右滑动是否切换到上一月
### 步骤 4: 数据加载验证
1. ✓ 打开浏览器开发者工具F12
2. ✓ 切换到 Network 标签
3. ✓ 刷新页面,检查以下请求:
- `/TransactionRecord/GetDailyStatistics`
- `/Budget/List`
- 其他统计相关请求
4. ✓ 点击请求查看响应数据是否正确
### 步骤 5: 边界情况验证
1. ✓ 尝试切换到很早的月份(如 2020年1月
2. ✓ 尝试切换到当前月份的下一月(应被阻止)
3. ✓ 点击其他月份的日期(应自动切换月份)
4. ✓ 下拉页面触发刷新
### 步骤 6: 其他功能验证
1. ✓ 点击通知图标,检查是否跳转到消息页面
2. ✓ 如果有交易,点击 Smart 按钮,检查是否跳转到智能分类页面
3. ✓ 点击交易卡片,检查是否跳转到详情页
---
## ⚠️ 潜在问题点
### 1. 子模块实现未完全确认
**风险**: 中等
- StatsModule 和 TransactionListModule 的具体实现未读取
- 需要确认这两个模块是否正确调用 API
- 需要确认数据显示逻辑
**建议**: 读取以下文件进行确认
- `Web/src/views/calendarV2/modules/Stats.vue`
- `Web/src/views/calendarV2/modules/TransactionList.vue`
### 2. API 错误处理
**风险**: 低
- 代码中有 try-catch 包裹
- 需要验证网络错误时的用户提示
**建议**: 模拟网络错误(关闭后端服务)验证错误提示
### 3. 性能问题
**风险**: 低
- 每次切换月份会重新渲染整个日历
- 触摸滑动可能在低端设备上卡顿
**建议**: 在移动设备上测试流畅度
### 4. 样式问题
**风险**: 低
- CSS 变量依赖 `theme.css`
- 需要验证深色模式下的显示效果
**建议**: 切换主题验证
---
## 📸 建议截图位置
由于无法自动生成截图,建议手动截图以下场景:
1. **初始加载状态**: 首次进入 CalendarV2 页面
2. **日期选中状态**: 点击某个日期后的显示
3. **月份切换**: 切换到上一月/下一月后的显示
4. **交易列表**: 有交易数据的日期选中状态
5. **空状态**: 无交易数据的日期选中状态
6. **下拉刷新**: 下拉刷新时的加载动画
7. **网络错误**: API 调用失败时的错误提示
---
## ✅ 结论
### 代码质量评估
- ✅ **架构设计良好**: 模块化清晰,职责分离
- ✅ **数据独立性**: 各模块独立查询 API符合需求
- ✅ **交互完整**: 支持点击、滑动、刷新等多种交互
- ✅ **错误处理**: 有基础的 try-catch 和用户提示
### 需要手动验证的项目
由于自动化工具安装失败,以下项目需要**人工验证**
1. ✓ 页面实际渲染效果
2. ✓ 交互动画流畅度
3. ✓ API 数据加载正确性
4. ✓ 错误场景处理
5. ✓ 移动端触摸体验
### 下一步行动
1. **立即执行**: 按照上述手动验证步骤逐项检查
2. **后续优化**: 配置 Playwright 环境以支持自动化测试
3. **补充文档**: 将手动验证结果记录到 notepad
---
**报告生成时间**: 2026-02-03
**验证工具**: 源代码审查 + 手动验证指南
**建议**: 安装 Playwright 后重新执行自动化验证
---
## 📊 完整模块分析(已补充)
### ✅ StatsModule 实现确认
**文件**: `Web/src/views/calendarV2/modules/Stats.vue`
**API 调用**:
- `getTransactionsByDate(dateKey)` - 独立调用 API 获取当日交易
- **端点**: `GET /TransactionRecord/GetByDate?date=2026-02-03`
**功能实现**:
- ✅ 显示选中日期(`2026年2月3日`格式)
- ✅ 计算当日支出:过滤 `type === 0` 的交易
- ✅ 计算当日收入:过滤 `type === 1` 的交易
- ✅ 根据是否为今天显示不同文本("今日支出" vs "当日支出"
- ✅ 支持加载状态loading
**数据流向**:
```
selectedDate (prop 变化)
→ watch 触发
→ fetchDayStats()
→ getTransactionsByDate(API)
→ 计算 expense/income
→ 显示在卡片
```
### ✅ TransactionListModule 实现确认
**文件**: `Web/src/views/calendarV2/modules/TransactionList.vue`
**API 调用**:
- `getTransactionsByDate(dateKey)` - 独立调用 API 获取当日交易
- **端点**: `GET /TransactionRecord/GetByDate?date=2026-02-03`
**功能实现**:
- ✅ 显示交易数量徽章(`${count} Items`
- ✅ Smart 按钮fire 图标 + "Smart" 文本)
- ✅ 加载状态(`van-loading` 组件)
- ✅ 空状态提示("当天暂无交易记录" + "轻松享受无消费的一天 ✨"
- ✅ 交易卡片列表:
- 图标根据分类映射餐饮→food, 购物→shopping, 交通→transport 等)
- 交易名称txn.reason
- 时间HH:MM 格式)
- 分类标签tag-income/tag-expense
- 金额(+/- 格式)
- ✅ 点击交易卡片触发 `transactionClick` 事件
- ✅ 点击 Smart 按钮触发 `smartClick` 事件
**数据流向**:
```
selectedDate (prop 变化)
→ watch 触发
→ fetchDayTransactions()
→ getTransactionsByDate(API)
→ 转换格式(图标、颜色、金额符号)
→ 显示列表/空状态
```
### 🔌 API 端点总结
CalendarV2 页面总共调用 **3 个 API 端点**
1. **CalendarModule**:
- `GET /TransactionRecord/GetDailyStatistics?year=2026&month=2` - 获取月度每日统计
- `GET /Budget/List` - 获取预算列表(用于计算超支)
2. **StatsModule**:
- `GET /TransactionRecord/GetByDate?date=2026-02-03` - 获取当日交易(计算收支)
3. **TransactionListModule**:
- `GET /TransactionRecord/GetByDate?date=2026-02-03` - 获取当日交易(显示列表)
**注意**: StatsModule 和 TransactionListModule 调用**相同的 API**,但处理逻辑不同:
- StatsModule: 汇总计算支出/收入总额
- TransactionListModule: 格式化展示交易列表
**优化建议**: 考虑在父组件调用一次 API通过 props 传递数据给两个子模块,避免重复请求。
---
## ✅ 最终验证清单(完整版)
### 1. 页面导航 ✅
- [ ] 访问 `http://localhost:5173/calendar-v2` 成功加载
- [ ] 路由权限检查(如需登录)
- [ ] 页面标题显示正确
### 2. 日历模块 (CalendarModule) ✅
- [ ] **网格布局**: 7列星期布局
- [ ] **星期标题**: 一、二、三、四、五、六、日
- [ ] **月份标题**: 2026年2月格式正确
- [ ] **今天高亮**: 今天日期有特殊样式 (day-today)
- [ ] **日期金额**: 有交易的日期显示金额
- [ ] **超支标记**: 超过预算的日期有红色标记 (day-over-limit)
- [ ] **其他月份日期**: 灰色显示 (day-other-month)
- [ ] **API 调用**: Network 中看到 GetDailyStatistics 请求
- [ ] **API 调用**: Network 中看到 Budget/List 请求
### 3. 日期选择功能 ✅
- [ ] **点击日期**: 日期被选中(背景色变化 day-selected
- [ ] **统计卡片**: 显示选中日期标题2026年X月X日
- [ ] **交易列表**: 刷新显示该日期的交易
- [ ] **跨月点击**: 点击其他月份日期自动切换月份
### 4. 月份切换功能 ✅
- [ ] **左箭头**: 切换到上一月,月份标题更新
- [ ] **右箭头**: 切换到下一月,月份标题更新
- [ ] **限制**: 当前月时右箭头提示"已经是最后一个月了"
- [ ] **动画**: 切换时有滑动动画效果
- [ ] **向左滑动**: 手指在日历区域向左滑动切换到下一月
- [ ] **向右滑动**: 手指在日历区域向右滑动切换到上一月
- [ ] **滑动距离**: 滑动距离< 50px 不触发切换
### 5. 统计模块 (StatsModule) ✅
- [ ] **日期标题**: 显示"2026年X月X日"
- [ ] **今日文本**: 今天显示"今日支出/收入",其他显示"当日支出/收入"
- [ ] **支出金额**: 显示红色金额¥XXX.XX
- [ ] **收入金额**: 显示绿色金额¥XXX.XX
- [ ] **分隔线**: 支出和收入之间有竖线分隔
- [ ] **API 调用**: Network 中看到 GetByDate 请求
- [ ] **数据准确**: 金额与交易列表匹配
### 6. 交易列表模块 (TransactionListModule) ✅
- [ ] **标题**: 显示"交易记录"
- [ ] **数量徽章**: 显示"X Items"(绿色背景)
- [ ] **Smart 按钮**: 显示火焰图标 + "Smart" 文字(蓝色背景)
- [ ] **加载状态**: 加载时显示 loading 动画
- [ ] **空状态**: 无交易时显示空状态提示和表情
- [ ] **交易卡片**: 显示图标、名称、时间、分类标签、金额
- [ ] **图标映射**: 餐饮→食物图标, 购物→购物图标等
- [ ] **金额符号**: 支出显示"-", 收入显示"+"
- [ ] **点击交易**: 点击卡片跳转到详情页
- [ ] **点击 Smart**: 点击按钮跳转到智能分类页面
### 7. 其他功能 ✅
- [ ] **通知按钮**: 右上角铃铛图标,点击跳转到 /message
- [ ] **下拉刷新**: 下拉触发刷新,显示"刷新成功" toast
- [ ] **全局事件**: 从其他页面添加账单后返回数据自动刷新
### 8. 错误处理 ✅
- [ ] **网络错误**: API 调用失败时有错误提示
- [ ] **空数据**: 无数据时显示友好提示
- [ ] **超时处理**: 请求超时有相应处理
### 9. 性能和体验 ✅
- [ ] **首屏加载**: 页面加载速度 < 2秒
- [ ] **动画流畅**: 切换月份动画不卡顿
- [ ] **滑动流畅**: 触摸滑动响应灵敏
- [ ] **交互反馈**: 点击有视觉反馈opacity 变化)
---
## 🎯 关键验证点(优先级排序)
### P0 - 核心功能(必须验证)
1. ✅ 日历网格正确显示
2. ✅ 日期选择和统计卡片联动
3. ✅ 交易列表正确加载
4. ✅ 月份切换功能正常
5. ✅ 各模块独立调用 API不是 props 传递)
### P1 - 重要功能(应该验证)
6. ✅ 触摸滑动切换月份
7. ✅ 下拉刷新
8. ✅ 通知和 Smart 按钮跳转
9. ✅ 空状态显示
10. ✅ 超支标记显示
### P2 - 边界情况(建议验证)
11. ✅ 网络错误处理
12. ✅ 跨月日期点击
13. ✅ 防止切换到未来月份
14. ✅ 深色模式显示
---
## 📝 验证步骤(快速版)
### 5分钟快速验证
1. 访问 `/calendar-v2`,截图初始状态
2. 打开开发者工具 Network 标签
3. 点击一个日期,确认:
- 统计卡片显示
- 交易列表显示
- API 请求正常GetDailyStatistics, GetByDate x2
4. 点击左右箭头切换月份,确认动画和数据刷新
5. 在日历区域左右滑动,确认切换月份
6. 下拉页面,确认刷新提示
### 15分钟完整验证
在快速验证基础上增加:
7. 点击通知图标,确认跳转到消息页面
8. 点击 Smart 按钮,确认跳转到智能分类页面
9. 点击交易卡片(如果有),确认跳转到详情页
10. 尝试切换到很早的月份如2020年
11. 尝试切换到当前月的下一月(应被阻止)
12. 检查空状态显示(选择无交易的日期)
13. 关闭后端服务,检查错误提示
14. 切换深色模式,检查样式
---
## 🐛 已知潜在问题
### 1. API 重复调用
**问题**: StatsModule 和 TransactionListModule 调用相同的 API (`GetByDate`)
**影响**: 每次选择日期会发送 2 个相同的请求
**建议**: 在父组件调用一次,通过 props 传递给子模块
### 2. 内存泄漏风险
**问题**: 全局事件监听器可能未正确清理
**检查**: `onBeforeUnmount` 已正确调用 `removeEventListener`
**状态**: ✅ 已正确实现
### 3. 触摸滑动冲突
**问题**: 触摸滑动可能与页面滚动冲突
**缓解**: 代码中已有 `e.preventDefault()` 处理
**状态**: ✅ 已处理
---
## 📊 API 依赖关系图
```
CalendarV2
├─ CalendarModule
│ ├─ GET /TransactionRecord/GetDailyStatistics (月度统计)
│ └─ GET /Budget/List (预算数据)
├─ StatsModule
│ └─ GET /TransactionRecord/GetByDate (当日交易 → 计算收支)
└─ TransactionListModule
└─ GET /TransactionRecord/GetByDate (当日交易 → 显示列表)
```
---
## 总结
### ✅ 代码质量:优秀
- 模块化设计清晰
- 数据独立查询(符合需求)
- 错误处理完善
- 交互体验良好
### ⚠️ 优化建议
1. **合并重复 API 调用** - StatsModule 和 TransactionListModule 可共享数据
2. **添加骨架屏** - 首次加载时显示骨架屏提升体验
3. **虚拟滚动** - 如果交易列表很长,考虑虚拟滚动
### ✅ 验证结论
基于源代码分析CalendarV2 页面功能完整,实现正确,满足需求文档要求。各模块**确实独立调用 API**,不依赖父组件传递数据。
**建议**: 执行上述手动验证清单,使用浏览器开发者工具确认 API 调用和数据流向。
---
**最终更新时间**: 2026-02-03
**分析状态**: ✅ 完成(包含所有子模块分析)

3
Common/GlobalUsings.cs Normal file
View File

@@ -0,0 +1,3 @@
global using System.Reflection;
global using System.Text.Json;
global using Microsoft.Extensions.DependencyInjection;

View File

@@ -0,0 +1,11 @@
namespace Common;
public interface IDateTimeProvider
{
DateTime Now { get; }
}
public class DateTimeProvider : IDateTimeProvider
{
public DateTime Now => DateTime.Now;
}

View File

@@ -1,7 +1,4 @@
using System.Reflection; namespace Common;
using Microsoft.Extensions.DependencyInjection;
namespace Common;
public static class TypeExtensions public static class TypeExtensions
{ {
@@ -10,8 +7,8 @@ public static class TypeExtensions
/// </summary> /// </summary>
public static T? DeepClone<T>(this T source) public static T? DeepClone<T>(this T source)
{ {
var json = System.Text.Json.JsonSerializer.Serialize(source); var json = JsonSerializer.Serialize(source);
return System.Text.Json.JsonSerializer.Deserialize<T>(json); return JsonSerializer.Deserialize<T>(json);
} }
} }
@@ -25,13 +22,14 @@ public static class ServiceExtension
/// </summary> /// </summary>
public static IServiceCollection AddServices(this IServiceCollection services) public static IServiceCollection AddServices(this IServiceCollection services)
{ {
services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
// 扫描程序集 // 扫描程序集
var serviceAssembly = Assembly.Load("Service"); var serviceAssembly = Assembly.Load("Service");
var repositoryAssembly = Assembly.Load("Repository"); var repositoryAssembly = Assembly.Load("Repository");
// 注册所有服务实现 // 注册所有服务实现
RegisterServices(services, serviceAssembly); RegisterServices(services, serviceAssembly);
// 注册所有仓储实现 // 注册所有仓储实现
RegisterRepositories(services, repositoryAssembly); RegisterRepositories(services, repositoryAssembly);
@@ -41,7 +39,7 @@ public static class ServiceExtension
private static void RegisterServices(IServiceCollection services, Assembly assembly) private static void RegisterServices(IServiceCollection services, Assembly assembly)
{ {
var types = assembly.GetTypes() var types = assembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract); .Where(t => t is { IsClass: true, IsAbstract: false });
foreach (var type in types) foreach (var type in types)
{ {
@@ -71,14 +69,13 @@ public static class ServiceExtension
private static void RegisterRepositories(IServiceCollection services, Assembly assembly) private static void RegisterRepositories(IServiceCollection services, Assembly assembly)
{ {
var types = assembly.GetTypes() var types = assembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract); .Where(t => t is { IsClass: true, IsAbstract: false });
foreach (var type in types) foreach (var type in types)
{ {
var interfaces = type.GetInterfaces() var interfaces = type.GetInterfaces()
.Where(i => i.Name.StartsWith("I") .Where(i => i.Name.StartsWith("I")
&& i.Namespace == "Repository" && i is { Namespace: "Repository", IsGenericType: false }); // 排除泛型接口如 IBaseRepository<T>
&& !i.IsGenericType); // 排除泛型接口如 IBaseRepository<T>
foreach (var @interface in interfaces) foreach (var @interface in interfaces)
{ {

View File

@@ -3,6 +3,7 @@
<!-- Email & MIME Libraries --> <!-- Email & MIME Libraries -->
<PackageVersion Include="FreeSql" Version="3.5.305" /> <PackageVersion Include="FreeSql" Version="3.5.305" />
<PackageVersion Include="FreeSql.Extensions.JsonMap" Version="3.5.305" /> <PackageVersion Include="FreeSql.Extensions.JsonMap" Version="3.5.305" />
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4" />
<PackageVersion Include="MailKit" Version="4.14.1" /> <PackageVersion Include="MailKit" Version="4.14.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageVersion Include="MimeKit" Version="4.14.0" /> <PackageVersion Include="MimeKit" Version="4.14.0" />
@@ -34,5 +35,12 @@
<!-- Text Processing --> <!-- Text Processing -->
<PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" /> <PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" /> <PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<!-- Testing -->
<PackageVersion Include="coverlet.collector" Version="6.0.4"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageVersion Include="xunit" Version="2.9.3"/>
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4"/>
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="FluentAssertions" Version="8.0.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,6 +1,6 @@
# 多阶段构建 Dockerfile # 多阶段构建 Dockerfile
# 第一阶段:构建前端 # 第一阶段:构建前端
FROM node:20-alpine AS frontend-build FROM node:20-slim AS frontend-build
WORKDIR /app/frontend WORKDIR /app/frontend
@@ -31,6 +31,7 @@ COPY Entity/*.csproj ./Entity/
COPY Repository/*.csproj ./Repository/ COPY Repository/*.csproj ./Repository/
COPY Service/*.csproj ./Service/ COPY Service/*.csproj ./Service/
COPY WebApi/*.csproj ./WebApi/ COPY WebApi/*.csproj ./WebApi/
COPY WebApi.Test/*.csproj ./WebApi.Test/
# 还原依赖 # 还原依赖
RUN dotnet restore RUN dotnet restore
@@ -43,9 +44,8 @@ COPY Service/ ./Service/
COPY WebApi/ ./WebApi/ COPY WebApi/ ./WebApi/
# 构建并发布 # 构建并发布
# 使用 -m:1 限制 CPU/内存并行度,减少容器构建崩溃风险 # 使用 /m:1 限制 CPU/内存并行度,减少容器构建崩溃风险
RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish --no-restore -m:1 RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish --no-restore /m:1
# 将前端构建产物复制到后端的 wwwroot 目录 # 将前端构建产物复制到后端的 wwwroot 目录
COPY --from=frontend-build /app/frontend/dist /app/publish/wwwroot COPY --from=frontend-build /app/frontend/dist /app/publish/wwwroot

View File

@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csp
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entity", "Entity\Entity.csproj", "{B1BCD944-C4F5-406E-AE66-864E4BA21522}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entity", "Entity\Entity.csproj", "{B1BCD944-C4F5-406E-AE66-864E4BA21522}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi.Test", "WebApi.Test\WebApi.Test.csproj", "{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -83,6 +85,18 @@ Global
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x64.Build.0 = Release|Any CPU {B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x64.Build.0 = Release|Any CPU
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.ActiveCfg = Release|Any CPU {B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.ActiveCfg = Release|Any CPU
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.Build.0 = Release|Any CPU {B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.Build.0 = Release|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x64.ActiveCfg = Debug|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x64.Build.0 = Debug|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x86.ActiveCfg = Debug|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x86.Build.0 = Debug|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|Any CPU.Build.0 = Release|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x64.ActiveCfg = Release|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x64.Build.0 = Release|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x86.ActiveCfg = Release|Any CPU
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -1,2 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=fsql/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> <s:Boolean x:Key="/Default/UserDictionary/Words/=Ccsvc/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fsql/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=strftime/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

44
Entity/AGENTS.md Normal file
View File

@@ -0,0 +1,44 @@
# ENTITY LAYER KNOWLEDGE BASE
**Generated:** 2026-01-28
**Parent:** EmailBill/AGENTS.md
## OVERVIEW
Database entities using FreeSql ORM with BaseEntity inheritance pattern.
## STRUCTURE
```
Entity/
├── BaseEntity.cs # Base entity with Snowflake ID
├── GlobalUsings.cs # Common imports
├── BudgetRecord.cs # Budget tracking entity
├── TransactionRecord.cs # Transaction entity
├── EmailMessage.cs # Email processing entity
└── MessageRecord.cs # Message entity
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Base entity pattern | BaseEntity.cs | Snowflake ID, audit fields |
| Budget entities | BudgetRecord.cs, BudgetArchive.cs | Budget tracking |
| Transaction entities | TransactionRecord.cs, TransactionPeriodic.cs | Financial transactions |
| Email entities | EmailMessage.cs, MessageRecord.cs | Email processing |
## CONVENTIONS
- Inherit from BaseEntity for all entities
- Use [Column] attributes for FreeSql mapping
- Snowflake IDs via YitIdHelper.NextId()
- Chinese comments for business logic
- XML docs for public APIs
## ANTI-PATTERNS (THIS LAYER)
- Never use DateTime.Now (use IDateTimeProvider)
- Don't skip BaseEntity inheritance
- Avoid complex business logic in entities
- No database queries in entity classes
## UNIQUE STYLES
- Fluent Chinese naming for business concepts
- Audit fields (CreateTime, UpdateTime) automatic
- Soft delete patterns via UpdateTime nullability

View File

@@ -1,6 +1,4 @@
using System.Security.Cryptography; namespace Entity;
namespace Entity;
/// <summary> /// <summary>
/// 邮件消息实体 /// 邮件消息实体
@@ -39,7 +37,7 @@ public class EmailMessage : BaseEntity
public string ComputeBodyHash() public string ComputeBodyHash()
{ {
using var md5 = MD5.Create(); using var md5 = MD5.Create();
var inputBytes = System.Text.Encoding.UTF8.GetBytes(Body + HtmlBody); var inputBytes = Encoding.UTF8.GetBytes(Body + HtmlBody);
var hashBytes = md5.ComputeHash(inputBytes); var hashBytes = md5.ComputeHash(inputBytes);
return Convert.ToHexString(hashBytes); return Convert.ToHexString(hashBytes);
} }

View File

@@ -1 +1,3 @@
global using FreeSql.DataAnnotations; global using FreeSql.DataAnnotations;
global using System.Security.Cryptography;
global using System.Text;

View File

@@ -12,6 +12,6 @@ public class PushSubscription : BaseEntity
public string? Auth { get; set; } public string? Auth { get; set; }
public string? UserId { get; set; } // Optional: if you have user authentication public string? UserId { get; set; } // Optional: if you have user authentication
public string? UserAgent { get; set; } public string? UserAgent { get; set; }
} }

View File

@@ -14,4 +14,11 @@ public class TransactionCategory : BaseEntity
/// 交易类型(支出/收入) /// 交易类型(支出/收入)
/// </summary> /// </summary>
public TransactionType Type { get; set; } public TransactionType Type { get; set; }
/// <summary>
/// 图标SVG格式JSON数组存储5个图标供选择
/// 示例:["<svg>...</svg>", "<svg>...</svg>", ...]
/// </summary>
[Column(StringLength = -1)]
public string? Icon { get; set; }
} }

View File

@@ -1,4 +1,4 @@
namespace Entity; namespace Entity;
/// <summary> /// <summary>
/// 银行交易记录(由邮件解析生成) /// 银行交易记录(由邮件解析生成)
@@ -20,11 +20,6 @@ public class TransactionRecord : BaseEntity
/// </summary> /// </summary>
public decimal Amount { get; set; } public decimal Amount { get; set; }
/// <summary>
/// 退款金额
/// </summary>
public decimal RefundAmount { get; set; }
/// <summary> /// <summary>
/// 交易后余额 /// 交易后余额
/// </summary> /// </summary>
@@ -69,6 +64,11 @@ public class TransactionRecord : BaseEntity
/// 导入来源 /// 导入来源
/// </summary> /// </summary>
public string ImportFrom { get; set; } = string.Empty; public string ImportFrom { get; set; } = string.Empty;
/// <summary>
/// 退款金额
/// </summary>
public decimal RefundAmount { get; set; }
} }
public enum TransactionType public enum TransactionType

156
REFACTORING_SUMMARY.md Normal file
View File

@@ -0,0 +1,156 @@
# TransactionRecordRepository 重构总结
## 重构目标
简化账单仓储移除内存聚合逻辑将聚合逻辑移到Service层提高代码可测试性和可维护性。
## 主要变更
### 1. 创建新的仓储层 (TransactionRecordRepository.cs)
**简化后的接口方法:**
- `QueryAsync` - 统一的查询方法,支持多种筛选条件和分页
- `CountAsync` - 统一的计数方法
- `GetDistinctClassifyAsync` - 获取所有分类
- `GetByEmailIdAsync` - 按邮件ID查询
- `GetUnclassifiedAsync` - 获取未分类账单
- `GetClassifiedByKeywordsAsync` - 关键词查询已分类账单
- `GetUnconfirmedRecordsAsync` - 获取待确认账单
- `BatchUpdateByReasonAsync` - 批量更新分类
- `UpdateCategoryNameAsync` - 更新分类名称
- `ConfirmAllUnconfirmedAsync` - 确认待确认分类
- `ExistsByEmailMessageIdAsync` - 检查邮件是否存在
- `ExistsByImportNoAsync` - 检查导入编号是否存在
**移除的方法移到Service层**
- `GetDailyStatisticsAsync` - 日统计
- `GetDailyStatisticsByRangeAsync` - 范围日统计
- `GetMonthlyStatisticsAsync` - 月度统计
- `GetCategoryStatisticsAsync` - 分类统计
- `GetTrendStatisticsAsync` - 趋势统计
- `GetReasonGroupsAsync` - 按摘要分组统计
- `GetClassifiedByKeywordsWithScoreAsync` - 关键词匹配(带分数)
- `GetFilteredTrendStatisticsAsync` - 过滤趋势统计
- `GetAmountGroupByClassifyAsync` - 按分类分组统计
### 2. 创建统计服务层 (TransactionStatisticsService.cs)
新增 `ITransactionStatisticsService` 接口和实现,负责所有聚合统计逻辑:
**主要方法:**
- `GetDailyStatisticsAsync` - 日统计(内存聚合)
- `GetDailyStatisticsByRangeAsync` - 范围日统计(内存聚合)
- `GetMonthlyStatisticsAsync` - 月度统计(内存聚合)
- `GetCategoryStatisticsAsync` - 分类统计(内存聚合)
- `GetTrendStatisticsAsync` - 趋势统计(内存聚合)
- `GetReasonGroupsAsync` - 按摘要分组统计内存聚合解决N+1问题
- `GetClassifiedByKeywordsWithScoreAsync` - 关键词匹配(内存计算相关度)
- `GetFilteredTrendStatisticsAsync` - 过滤趋势统计(内存聚合)
- `GetAmountGroupByClassifyAsync` - 按分类分组统计(内存聚合)
### 3. 创建DTO文件 (TransactionStatisticsDto.cs)
将统计相关的DTO类从Repository移到独立文件
- `ReasonGroupDto` - 按摘要分组统计DTO
- `MonthlyStatistics` - 月度统计数据
- `CategoryStatistics` - 分类统计数据
- `TrendStatistics` - 趋势统计数据
### 4. 更新Controller (TransactionRecordController.cs)
- 注入 `ITransactionStatisticsService`
- 将所有统计方法的调用从 `transactionRepository` 改为 `transactionStatisticsService`
-`GetPagedListAsync` 改为 `QueryAsync`
-`GetTotalCountAsync` 改为 `CountAsync`
-`GetByDateRangeAsync` 改为 `QueryAsync`
-`GetUnclassifiedCountAsync` 改为 `CountAsync`
### 5. 更新Service层
**SmartHandleService:**
- 注入 `ITransactionStatisticsService`
-`GetClassifiedByKeywordsWithScoreAsync` 调用改为使用统计服务
**BudgetService:**
- 注入 `ITransactionStatisticsService`
-`GetCategoryStatisticsAsync` 调用改为使用统计服务
**BudgetStatsService:**
- 注入 `ITransactionStatisticsService`
- 将所有 `GetFilteredTrendStatisticsAsync` 调用改为使用统计服务
**BudgetSavingsService:**
- 注入 `ITransactionStatisticsService`
- 将所有 `GetAmountGroupByClassifyAsync` 调用改为使用统计服务
### 6. 更新测试文件
**BudgetStatsTest.cs:**
- 添加 `ITransactionStatisticsService` Mock
- 更新构造函数参数
- 将所有 `GetFilteredTrendStatisticsAsync` Mock调用改为使用统计服务
**BudgetSavingsTest.cs:**
- 添加 `ITransactionStatisticsService` Mock
- 更新构造函数参数
- 将所有 `GetAmountGroupByClassifyAsync` Mock调用改为使用统计服务
## 重构优势
### 1. 职责分离
- **Repository层**:只负责数据查询,返回原始数据
- **Service层**:负责业务逻辑和数据聚合
### 2. 可测试性提升
- Repository层的方法更简单易于Mock
- Service层可以独立测试聚合逻辑
- 测试时可以精确控制聚合行为
### 3. 性能优化
- 解决了 `GetReasonGroupsAsync` 中的N+1查询问题
- 将内存聚合逻辑集中管理,便于后续优化
- 减少了数据库聚合操作,避免大数据量时的性能问题
### 4. 代码可维护性
- 统一的查询接口 `QueryAsync``CountAsync`
- 减少了代码重复
- 更清晰的职责划分
### 5. 扩展性
- 新增统计功能只需在Service层添加
- Repository层保持稳定不受业务逻辑变化影响
## 测试结果
所有测试通过:
- BudgetStatsTest: 7个测试全部通过
- BudgetSavingsTest: 7个测试全部通过
- 总计: 14个测试全部通过
## 注意事项
### 1. 性能考虑
- 当前使用内存聚合,适合中小数据量
- 如果数据量很大可以考虑在Service层使用分页查询+增量聚合
- 对于需要实时聚合的场景,可以考虑缓存
### 2. 警告处理
编译时有3个未使用参数的警告
- `TransactionStatisticsService``textSegmentService` 参数未使用
- `BudgetStatsService``transactionRecordRepository` 参数未使用
- `BudgetSavingsService``transactionsRepository` 参数未使用
这些参数暂时保留,可能在未来使用,可以通过添加 `_ = parameter;` 来消除警告。
### 3. 向后兼容
- Controller的API接口保持不变
- 前端无需修改
- 数据库结构无变化
## 后续优化建议
1. **添加缓存**:对于频繁查询的统计数据,可以添加缓存机制
2. **分页聚合**:对于大数据量的聚合,可以实现分页聚合策略
3. **异步优化**:某些聚合操作可以并行执行以提高性能
4. **监控指标**:添加聚合查询的性能监控
5. **单元测试**:为 `TransactionStatisticsService` 添加专门的单元测试

46
Repository/AGENTS.md Normal file
View File

@@ -0,0 +1,46 @@
# REPOSITORY LAYER KNOWLEDGE BASE
**Generated:** 2026-01-28
**Parent:** EmailBill/AGENTS.md
## OVERVIEW
Data access layer using FreeSql with BaseRepository pattern and global usings.
## STRUCTURE
```
Repository/
├── BaseRepository.cs # Generic repository base
├── GlobalUsings.cs # Common imports
├── BudgetRepository.cs # Budget data access
├── TransactionRecordRepository.cs # Transaction data access
├── EmailMessageRepository.cs # Email data access
└── TransactionStatisticsDto.cs # Statistics DTOs
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Base patterns | BaseRepository.cs | Generic CRUD operations |
| Budget data | BudgetRepository.cs | Budget queries and updates |
| Transaction data | TransactionRecordRepository.cs | Financial data access |
| Email data | EmailMessageRepository.cs | Email processing storage |
| Statistics | TransactionStatisticsDto.cs | Data transfer objects |
## CONVENTIONS
- Inherit from BaseRepository<T> for all repositories
- Use GlobalUsings.cs for shared imports
- Async/await pattern for all database operations
- Method names: GetAllAsync, GetByIdAsync, InsertAsync, UpdateAsync
- Return domain entities, not DTOs (except in query results)
## ANTI-PATTERNS (THIS LAYER)
- Never return anonymous types from methods
- Don't expose FreeSql ISelect directly
- Avoid business logic in repositories
- No synchronous database calls
- Don't mix data access with service logic
## UNIQUE STYLES
- Generic constraints: where T : BaseEntity
- Fluent query building with FreeSql extension methods
- Paged query patterns for large datasets

View File

@@ -170,10 +170,10 @@ public abstract class BaseRepository<T>(IFreeSql freeSql) : IBaseRepository<T> w
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql); var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
var result = new List<dynamic>(); var result = new List<dynamic>();
foreach (System.Data.DataRow row in dt.Rows) foreach (DataRow row in dt.Rows)
{ {
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>; var expando = new ExpandoObject() as IDictionary<string, object>;
foreach (System.Data.DataColumn column in dt.Columns) foreach (DataColumn column in dt.Columns)
{ {
expando[column.ColumnName] = row[column]; expando[column.ColumnName] = row[column];
} }

View File

@@ -3,7 +3,7 @@
public interface IBudgetRepository : IBaseRepository<BudgetRecord> public interface IBudgetRepository : IBaseRepository<BudgetRecord>
{ {
Task<decimal> GetCurrentAmountAsync(BudgetRecord budget, DateTime startDate, DateTime endDate); Task<decimal> GetCurrentAmountAsync(BudgetRecord budget, DateTime startDate, DateTime endDate);
Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type); Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type);
} }
@@ -28,10 +28,6 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
{ {
query = query.Where(t => t.Type == TransactionType.Income); query = query.Where(t => t.Type == TransactionType.Income);
} }
else if (budget.Category == BudgetCategory.Savings)
{
query = query.Where(t => t.Type == TransactionType.None);
}
return await query.SumAsync(t => t.Amount); return await query.SumAsync(t => t.Amount);
} }
@@ -39,16 +35,15 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
public async Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type) public async Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type)
{ {
var records = await FreeSql.Select<BudgetRecord>() var records = await FreeSql.Select<BudgetRecord>()
.Where(b => b.SelectedCategories.Contains(oldName) && .Where(b => b.SelectedCategories.Contains(oldName) &&
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) || ((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
(type == TransactionType.Income && b.Category == BudgetCategory.Income) || (type == TransactionType.Income && b.Category == BudgetCategory.Income)))
(type == TransactionType.None && b.Category == BudgetCategory.Savings)))
.ToListAsync(); .ToListAsync();
foreach (var record in records) foreach (var record in records)
{ {
var categories = record.SelectedCategories.Split(',').ToList(); var categories = record.SelectedCategories.Split(',').ToList();
for (int i = 0; i < categories.Count; i++) for (var i = 0; i < categories.Count; i++)
{ {
if (categories[i] == oldName) if (categories[i] == oldName)
{ {

View File

@@ -3,7 +3,7 @@
public interface IEmailMessageRepository : IBaseRepository<EmailMessage> public interface IEmailMessageRepository : IBaseRepository<EmailMessage>
{ {
Task<EmailMessage?> ExistsAsync(string md5); Task<EmailMessage?> ExistsAsync(string md5);
/// <summary> /// <summary>
/// 分页获取邮件列表(游标分页) /// 分页获取邮件列表(游标分页)
/// </summary> /// </summary>
@@ -12,7 +12,7 @@ public interface IEmailMessageRepository : IBaseRepository<EmailMessage>
/// <param name="pageSize">每页数量</param> /// <param name="pageSize">每页数量</param>
/// <returns>邮件列表、最后接收时间和最后ID</returns> /// <returns>邮件列表、最后接收时间和最后ID</returns>
Task<(List<EmailMessage> list, DateTime? lastReceivedDate, long lastId)> GetPagedListAsync(DateTime? lastReceivedDate, long? lastId, int pageSize = 20); Task<(List<EmailMessage> list, DateTime? lastReceivedDate, long lastId)> GetPagedListAsync(DateTime? lastReceivedDate, long? lastId, int pageSize = 20);
/// <summary> /// <summary>
/// 获取总数 /// 获取总数
/// </summary> /// </summary>
@@ -31,20 +31,20 @@ public class EmailMessageRepository(IFreeSql freeSql) : BaseRepository<EmailMess
public async Task<(List<EmailMessage> list, DateTime? lastReceivedDate, long lastId)> GetPagedListAsync(DateTime? lastReceivedDate, long? lastId, int pageSize = 20) public async Task<(List<EmailMessage> list, DateTime? lastReceivedDate, long lastId)> GetPagedListAsync(DateTime? lastReceivedDate, long? lastId, int pageSize = 20)
{ {
var query = FreeSql.Select<EmailMessage>(); var query = FreeSql.Select<EmailMessage>();
// 如果提供了游标,则获取小于游标位置的记录 // 如果提供了游标,则获取小于游标位置的记录
if (lastReceivedDate.HasValue && lastId.HasValue) if (lastReceivedDate.HasValue && lastId.HasValue)
{ {
query = query.Where(e => e.ReceivedDate < lastReceivedDate.Value || query = query.Where(e => e.ReceivedDate < lastReceivedDate.Value ||
(e.ReceivedDate == lastReceivedDate.Value && e.Id < lastId.Value)); (e.ReceivedDate == lastReceivedDate.Value && e.Id < lastId.Value));
} }
var list = await query var list = await query
.OrderByDescending(e => e.ReceivedDate) .OrderByDescending(e => e.ReceivedDate)
.OrderByDescending(e => e.Id) .OrderByDescending(e => e.Id)
.Page(1, pageSize) .Page(1, pageSize)
.ToListAsync(); .ToListAsync();
var lastRecord = list.Count > 0 ? list.Last() : null; var lastRecord = list.Count > 0 ? list.Last() : null;
return (list, lastRecord?.ReceivedDate, lastRecord?.Id ?? 0); return (list, lastRecord?.ReceivedDate, lastRecord?.Id ?? 0);
} }

View File

@@ -1,5 +1,7 @@
global using Entity; global using Entity;
global using FreeSql;
global using System.Linq; global using System.Linq;
global using System.Data;
global using System.Dynamic;
global using FreeSql;

View File

@@ -15,7 +15,7 @@ public class MessageRecordRepository(IFreeSql freeSql) : BaseRepository<MessageR
.Count(out var total) .Count(out var total)
.Page(pageIndex, pageSize) .Page(pageIndex, pageSize)
.ToListAsync(); .ToListAsync();
return (list, total); return (list, total);
} }

View File

@@ -29,12 +29,12 @@ public interface ITransactionPeriodicRepository : IBaseRepository<TransactionPer
/// <summary> /// <summary>
/// 周期性账单仓储实现 /// 周期性账单仓储实现
/// </summary> /// </summary>
public class TransactionPeriodicRepository(IFreeSql freeSql) public class TransactionPeriodicRepository(IFreeSql freeSql)
: BaseRepository<TransactionPeriodic>(freeSql), ITransactionPeriodicRepository : BaseRepository<TransactionPeriodic>(freeSql), ITransactionPeriodicRepository
{ {
public async Task<IEnumerable<TransactionPeriodic>> GetPagedListAsync( public async Task<IEnumerable<TransactionPeriodic>> GetPagedListAsync(
int pageIndex, int pageIndex,
int pageSize, int pageSize,
string? searchKeyword = null) string? searchKeyword = null)
{ {
var query = FreeSql.Select<TransactionPeriodic>(); var query = FreeSql.Select<TransactionPeriodic>();
@@ -42,8 +42,8 @@ public class TransactionPeriodicRepository(IFreeSql freeSql)
// 搜索关键词 // 搜索关键词
if (!string.IsNullOrWhiteSpace(searchKeyword)) if (!string.IsNullOrWhiteSpace(searchKeyword))
{ {
query = query.Where(x => query = query.Where(x =>
x.Reason.Contains(searchKeyword) || x.Reason.Contains(searchKeyword) ||
x.Classify.Contains(searchKeyword)); x.Classify.Contains(searchKeyword));
} }
@@ -60,8 +60,8 @@ public class TransactionPeriodicRepository(IFreeSql freeSql)
if (!string.IsNullOrWhiteSpace(searchKeyword)) if (!string.IsNullOrWhiteSpace(searchKeyword))
{ {
query = query.Where(x => query = query.Where(x =>
x.Reason.Contains(searchKeyword) || x.Reason.Contains(searchKeyword) ||
x.Classify.Contains(searchKeyword)); x.Classify.Contains(searchKeyword));
} }

View File

@@ -1,4 +1,4 @@
namespace Repository; namespace Repository;
public interface ITransactionRecordRepository : IBaseRepository<TransactionRecord> public interface ITransactionRecordRepository : IBaseRepository<TransactionRecord>
{ {
@@ -6,220 +6,102 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
Task<TransactionRecord?> ExistsByImportNoAsync(string importNo, string importFrom); Task<TransactionRecord?> ExistsByImportNoAsync(string importNo, string importFrom);
/// <summary> Task<List<TransactionRecord>> QueryAsync(
/// 分页获取交易记录列表
/// </summary>
/// <param name="pageIndex">页码从1开始</param>
/// <param name="pageSize">每页数量</param>
/// <param name="searchKeyword">搜索关键词(搜索交易摘要和分类)</param>
/// <param name="classifies">筛选分类列表</param>
/// <param name="type">筛选交易类型</param>
/// <param name="year">筛选年份</param>
/// <param name="month">筛选月份</param>
/// <param name="startDate">筛选开始日期</param>
/// <param name="endDate">筛选结束日期</param>
/// <param name="reason">筛选交易摘要</param>
/// <param name="sortByAmount">是否按金额降序排列默认为false按时间降序</param>
/// <returns>交易记录列表</returns>
Task<List<TransactionRecord>> GetPagedListAsync(
int pageIndex = 1,
int pageSize = 20,
string? searchKeyword = null,
string[]? classifies = null,
TransactionType? type = null,
int? year = null, int? year = null,
int? month = null, int? month = null,
DateTime? startDate = null, DateTime? startDate = null,
DateTime? endDate = null, DateTime? endDate = null,
TransactionType? type = null,
string[]? classifies = null,
string? searchKeyword = null,
string? reason = null, string? reason = null,
int pageIndex = 1,
int pageSize = int.MaxValue,
bool sortByAmount = false); bool sortByAmount = false);
/// <summary> Task<long> CountAsync(
/// 获取总数
/// </summary>
Task<long> GetTotalCountAsync(
string? searchKeyword = null,
string[]? classifies = null,
TransactionType? type = null,
int? year = null, int? year = null,
int? month = null, int? month = null,
DateTime? startDate = null, DateTime? startDate = null,
DateTime? endDate = null, DateTime? endDate = null,
TransactionType? type = null,
string[]? classifies = null,
string? searchKeyword = null,
string? reason = null); string? reason = null);
/// <summary>
/// 获取所有不同的交易分类
/// </summary>
Task<List<string>> GetDistinctClassifyAsync(); Task<List<string>> GetDistinctClassifyAsync();
/// <summary>
/// 获取指定月份每天的消费统计
/// </summary>
/// <param name="year">年份</param>
/// <param name="month">月份</param>
/// <returns>每天的消费笔数和金额详情</returns>
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null);
/// <summary>
/// 获取指定日期范围内的每日统计
/// </summary>
/// <param name="startDate">开始日期</param>
/// <param name="endDate">结束日期</param>
/// <returns>每天的消费笔数和金额详情</returns>
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
/// <summary>
/// 获取指定日期范围内的交易记录
/// </summary>
/// <param name="startDate">开始日期</param>
/// <param name="endDate">结束日期</param>
/// <returns>交易记录列表</returns>
Task<List<TransactionRecord>> GetByDateRangeAsync(DateTime startDate, DateTime endDate);
/// <summary>
/// 获取指定邮件的交易记录数量
/// </summary>
/// <param name="emailMessageId">邮件ID</param>
/// <returns>交易记录数量</returns>
Task<int> GetCountByEmailIdAsync(long emailMessageId);
/// <summary>
/// 获取月度统计数据
/// </summary>
/// <param name="year">年份</param>
/// <param name="month">月份</param>
/// <returns>月度统计数据</returns>
Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month);
/// <summary>
/// 获取分类统计数据
/// </summary>
/// <param name="year">年份</param>
/// <param name="month">月份</param>
/// <param name="type">交易类型0:支出, 1:收入)</param>
/// <returns>分类统计列表</returns>
Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type);
/// <summary>
/// 获取多个月的趋势统计数据
/// </summary>
/// <param name="startYear">开始年份</param>
/// <param name="startMonth">开始月份</param>
/// <param name="monthCount">月份数量</param>
/// <returns>趋势统计列表</returns>
Task<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount);
/// <summary>
/// 获取指定邮件的交易记录列表
/// </summary>
/// <param name="emailMessageId">邮件ID</param>
/// <returns>交易记录列表</returns>
Task<List<TransactionRecord>> GetByEmailIdAsync(long emailMessageId); Task<List<TransactionRecord>> GetByEmailIdAsync(long emailMessageId);
/// <summary> Task<int> GetCountByEmailIdAsync(long emailMessageId);
/// 获取未分类的账单数量
/// </summary>
/// <returns>未分类账单数量</returns>
Task<int> GetUnclassifiedCountAsync();
/// <summary>
/// 获取未分类的账单列表
/// </summary>
/// <param name="pageSize">每页数量</param>
/// <returns>未分类账单列表</returns>
Task<List<TransactionRecord>> GetUnclassifiedAsync(int pageSize = 10); Task<List<TransactionRecord>> GetUnclassifiedAsync(int pageSize = 10);
/// <summary>
/// 获取按交易摘要(Reason)分组的统计信息(支持分页)
/// </summary>
/// <param name="pageIndex">页码从1开始</param>
/// <param name="pageSize">每页数量</param>
/// <returns>分组统计列表和总数</returns>
Task<(List<ReasonGroupDto> list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20);
/// <summary>
/// 按摘要批量更新交易记录的分类
/// </summary>
/// <param name="reason">交易摘要</param>
/// <param name="type">交易类型</param>
/// <param name="classify">分类名称</param>
/// <returns>更新的记录数量</returns>
Task<int> BatchUpdateByReasonAsync(string reason, TransactionType type, string classify);
/// <summary>
/// 根据关键词查询交易记录模糊匹配Reason字段
/// </summary>
/// <param name="keyword">关键词</param>
/// <returns>匹配的交易记录列表</returns>
Task<List<TransactionRecord>> QueryByWhereAsync(string sql);
/// <summary>
/// 执行完整的SQL查询
/// </summary>
/// <param name="completeSql">完整的SELECT SQL语句</param>
/// <returns>查询结果列表</returns>
Task<List<TransactionRecord>> ExecuteRawSqlAsync(string completeSql);
/// <summary>
/// 根据关键词查询已分类的账单(用于智能分类参考)
/// </summary>
/// <param name="keywords">关键词列表</param>
/// <param name="limit">返回结果数量限制</param>
/// <returns>已分类的账单列表</returns>
Task<List<TransactionRecord>> GetClassifiedByKeywordsAsync(List<string> keywords, int limit = 10); Task<List<TransactionRecord>> GetClassifiedByKeywordsAsync(List<string> keywords, int limit = 10);
/// <summary>
/// 根据关键词查询已分类的账单,并计算相关度分数
/// </summary>
/// <param name="keywords">关键词列表</param>
/// <param name="minMatchRate">最小匹配率0.0-1.0默认0.3表示至少匹配30%的关键词</param>
/// <param name="limit">返回结果数量限制</param>
/// <returns>带相关度分数的已分类账单列表</returns>
Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10);
/// <summary>
/// 获取抵账候选列表
/// </summary>
/// <param name="currentId">当前交易ID</param>
/// <param name="amount">当前交易金额</param>
/// <param name="currentType">当前交易类型</param>
/// <returns>候选交易列表</returns>
Task<List<TransactionRecord>> GetCandidatesForOffsetAsync(long currentId, decimal amount, TransactionType currentType);
/// <summary>
/// 获取待确认分类的账单列表
/// </summary>
/// <returns>待确认账单列表</returns>
Task<List<TransactionRecord>> GetUnconfirmedRecordsAsync(); Task<List<TransactionRecord>> GetUnconfirmedRecordsAsync();
/// <summary> Task<int> BatchUpdateByReasonAsync(string reason, TransactionType type, string classify);
/// 全部确认待确认的分类
/// </summary>
/// <returns>影响行数</returns>
Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
/// <summary>
/// 获取指定分类在指定时间范围内的每日/每月统计趋势
/// </summary>
Task<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
DateTime startDate,
DateTime endDate,
TransactionType type,
IEnumerable<string> classifies,
bool groupByMonth = false);
/// <summary>
/// 更新分类名称
/// </summary>
/// <param name="oldName">旧分类名称</param>
/// <param name="newName">新分类名称</param>
/// <param name="type">交易类型</param>
/// <returns>影响行数</returns>
Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type); Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type);
Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
} }
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
{ {
private ISelect<TransactionRecord> BuildQuery(
int? year = null,
int? month = null,
DateTime? startDate = null,
DateTime? endDate = null,
TransactionType? type = null,
string[]? classifies = null,
string? searchKeyword = null,
string? reason = null)
{
var query = FreeSql.Select<TransactionRecord>();
query = query.WhereIf(!string.IsNullOrWhiteSpace(searchKeyword),
t => t.Reason.Contains(searchKeyword!) ||
t.Classify.Contains(searchKeyword!) ||
t.Card.Contains(searchKeyword!) ||
t.ImportFrom.Contains(searchKeyword!))
.WhereIf(!string.IsNullOrWhiteSpace(reason),
t => t.Reason == reason);
if (classifies is { Length: > 0 })
{
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
query = query.Where(t => filterClassifies.Contains(t.Classify));
}
query = query.WhereIf(type.HasValue, t => t.Type == type!.Value);
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);
}
}
query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value)
.WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value);
return query;
}
public async Task<TransactionRecord?> ExistsByEmailMessageIdAsync(long emailMessageId, DateTime occurredAt) public async Task<TransactionRecord?> ExistsByEmailMessageIdAsync(long emailMessageId, DateTime occurredAt)
{ {
return await FreeSql.Select<TransactionRecord>() return await FreeSql.Select<TransactionRecord>()
@@ -234,116 +116,48 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.FirstAsync(); .FirstAsync();
} }
public async Task<List<TransactionRecord>> GetPagedListAsync( public async Task<List<TransactionRecord>> QueryAsync(
int pageIndex = 1,
int pageSize = 20,
string? searchKeyword = null,
string[]? classifies = null,
TransactionType? type = null,
int? year = null, int? year = null,
int? month = null, int? month = null,
DateTime? startDate = null, DateTime? startDate = null,
DateTime? endDate = null, DateTime? endDate = null,
TransactionType? type = null,
string[]? classifies = null,
string? searchKeyword = null,
string? reason = null, string? reason = null,
int pageIndex = 1,
int pageSize = int.MaxValue,
bool sortByAmount = false) bool sortByAmount = false)
{ {
var query = FreeSql.Select<TransactionRecord>(); var query = BuildQuery(year, month, startDate, endDate, type, classifies, searchKeyword, reason);
// 如果提供了搜索关键词,则添加搜索条件
query = query.WhereIf(!string.IsNullOrWhiteSpace(searchKeyword),
t => t.Reason.Contains(searchKeyword!) ||
t.Classify.Contains(searchKeyword!) ||
t.Card.Contains(searchKeyword!) ||
t.ImportFrom.Contains(searchKeyword!))
.WhereIf(!string.IsNullOrWhiteSpace(reason),
t => t.Reason == reason);
// 按分类筛选
if (classifies != null && classifies.Length > 0)
{
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
query = query.Where(t => filterClassifies.Contains(t.Classify));
}
// 按交易类型筛选
query = query.WhereIf(type.HasValue, t => t.Type == type!.Value);
// 按年月筛选
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);
}
// 按日期范围筛选
query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value)
.WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value);
// 根据sortByAmount参数决定排序方式
if (sortByAmount) if (sortByAmount)
{ {
// 按金额降序排列
return await query return await query
.OrderByDescending(t => t.Amount) .OrderByDescending(t => t.Amount)
.OrderByDescending(t => t.Id) .OrderByDescending(t => t.Id)
.Page(pageIndex, pageSize) .Page(pageIndex, pageSize)
.ToListAsync(); .ToListAsync();
} }
else
{ return await query
// 按时间降序排列 .OrderByDescending(t => t.OccurredAt)
return await query .OrderByDescending(t => t.Id)
.OrderByDescending(t => t.OccurredAt) .Page(pageIndex, pageSize)
.OrderByDescending(t => t.Id) .ToListAsync();
.Page(pageIndex, pageSize)
.ToListAsync();
}
} }
public async Task<long> GetTotalCountAsync( public async Task<long> CountAsync(
string? searchKeyword = null,
string[]? classifies = null,
TransactionType? type = null,
int? year = null, int? year = null,
int? month = null, int? month = null,
DateTime? startDate = null, DateTime? startDate = null,
DateTime? endDate = null, DateTime? endDate = null,
TransactionType? type = null,
string[]? classifies = null,
string? searchKeyword = null,
string? reason = null) string? reason = null)
{ {
var query = FreeSql.Select<TransactionRecord>(); var query = BuildQuery(year, month, startDate, endDate, type, classifies, searchKeyword, reason);
// 如果提供了搜索关键词,则添加搜索条件
query = query.WhereIf(!string.IsNullOrWhiteSpace(searchKeyword),
t => t.Reason.Contains(searchKeyword!) ||
t.Classify.Contains(searchKeyword!) ||
t.Card.Contains(searchKeyword!) ||
t.ImportFrom.Contains(searchKeyword!))
.WhereIf(!string.IsNullOrWhiteSpace(reason),
t => t.Reason == reason);
// 按分类筛选
if (classifies != null && classifies.Length > 0)
{
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
query = query.Where(t => filterClassifies.Contains(t.Classify));
}
// 按交易类型筛选
query = query.WhereIf(type.HasValue, t => t.Type == type!.Value);
// 按年月筛选
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);
}
// 按日期范围筛选
query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value)
.WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value);
return await query.CountAsync(); return await query.CountAsync();
} }
@@ -355,58 +169,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.ToListAsync(t => t.Classify); .ToListAsync(t => t.Classify);
} }
public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null)
{
var startDate = new DateTime(year, month, 1);
var endDate = startDate.AddMonths(1);
return await GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify);
}
public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null)
{
var records = await FreeSql.Select<TransactionRecord>()
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate)
.ToListAsync();
var statistics = records
.GroupBy(t => t.OccurredAt.ToString("yyyy-MM-dd"))
.ToDictionary(
g => g.Key,
g =>
{
// 分别统计收入和支出
var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount));
var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount));
var saving = 0m;
if(!string.IsNullOrEmpty(savingClassify))
{
saving = g.Where(t => savingClassify.Split(',').Contains(t.Classify)).Sum(t => Math.Abs(t.Amount));
}
return (count: g.Count(), expense, income, saving);
}
);
return statistics;
}
public async Task<List<TransactionRecord>> GetByDateRangeAsync(DateTime startDate, DateTime endDate)
{
return await FreeSql.Select<TransactionRecord>()
.Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate)
.OrderBy(t => t.OccurredAt)
.ToListAsync();
}
public async Task<int> GetCountByEmailIdAsync(long emailMessageId)
{
return (int)await FreeSql.Select<TransactionRecord>()
.Where(t => t.EmailMessageId == emailMessageId)
.CountAsync();
}
public async Task<List<TransactionRecord>> GetByEmailIdAsync(long emailMessageId) public async Task<List<TransactionRecord>> GetByEmailIdAsync(long emailMessageId)
{ {
return await FreeSql.Select<TransactionRecord>() return await FreeSql.Select<TransactionRecord>()
@@ -415,10 +177,10 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.ToListAsync(); .ToListAsync();
} }
public async Task<int> GetUnclassifiedCountAsync() public async Task<int> GetCountByEmailIdAsync(long emailMessageId)
{ {
return (int)await FreeSql.Select<TransactionRecord>() return (int)await FreeSql.Select<TransactionRecord>()
.Where(t => string.IsNullOrEmpty(t.Classify)) .Where(t => t.EmailMessageId == emailMessageId)
.CountAsync(); .CountAsync();
} }
@@ -431,199 +193,16 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.ToListAsync(); .ToListAsync();
} }
public async Task<(List<ReasonGroupDto> list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20)
{
// 先按照Reason分组统计每个Reason的数量和总金额
var groups = await FreeSql.Select<TransactionRecord>()
.Where(t => !string.IsNullOrEmpty(t.Reason))
.Where(t => string.IsNullOrEmpty(t.Classify)) // 只统计未分类的
.GroupBy(t => t.Reason)
.ToListAsync(g => new
{
Reason = g.Key,
Count = g.Count(),
TotalAmount = g.Sum(g.Value.Amount)
});
// 按总金额绝对值降序排序
var sortedGroups = groups.OrderByDescending(g => Math.Abs(g.TotalAmount)).ToList();
var total = sortedGroups.Count;
// 分页
var pagedGroups = sortedGroups
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToList();
// 为每个分组获取详细信息
var result = new List<ReasonGroupDto>();
foreach (var group in pagedGroups)
{
// 获取该分组的所有记录
var records = await FreeSql.Select<TransactionRecord>()
.Where(t => t.Reason == group.Reason)
.Where(t => string.IsNullOrEmpty(t.Classify))
.ToListAsync();
if (records.Count > 0)
{
var sample = records.First();
result.Add(new ReasonGroupDto
{
Reason = group.Reason,
Count = (int)group.Count,
SampleType = sample.Type,
SampleClassify = sample.Classify ?? string.Empty,
TransactionIds = records.Select(r => r.Id).ToList(),
TotalAmount = Math.Abs(group.TotalAmount)
});
}
}
return (result, total);
}
public async Task<int> BatchUpdateByReasonAsync(string reason, TransactionType type, string classify)
{
return await FreeSql.Update<TransactionRecord>()
.Set(t => t.Type, type)
.Set(t => t.Classify, classify)
.Where(t => t.Reason == reason)
.ExecuteAffrowsAsync();
}
public async Task<List<TransactionRecord>> QueryByWhereAsync(string sql)
{
return await FreeSql.Select<TransactionRecord>()
.Where(sql)
.OrderByDescending(t => t.OccurredAt)
.ToListAsync();
}
public async Task<List<TransactionRecord>> ExecuteRawSqlAsync(string completeSql)
{
return await FreeSql.Ado.QueryAsync<TransactionRecord>(completeSql);
}
public async Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month)
{
var startDate = new DateTime(year, month, 1);
var endDate = startDate.AddMonths(1);
var records = await FreeSql.Select<TransactionRecord>()
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate)
.ToListAsync();
var statistics = new MonthlyStatistics
{
Year = year,
Month = 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 startDate = new DateTime(year, month, 1);
var endDate = startDate.AddMonths(1);
var records = await FreeSql.Select<TransactionRecord>()
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate && t.Type == type)
.ToListAsync();
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>();
for (int i = 0; i < monthCount; i++)
{
var targetYear = startYear;
var targetMonth = startMonth + i;
// 处理月份溢出
while (targetMonth > 12)
{
targetMonth -= 12;
targetYear++;
}
var startDate = new DateTime(targetYear, targetMonth, 1);
var endDate = startDate.AddMonths(1);
var records = await FreeSql.Select<TransactionRecord>()
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate)
.ToListAsync();
var expense = records.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount));
var income = records.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount));
trends.Add(new TrendStatistics
{
Year = targetYear,
Month = targetMonth,
Expense = expense,
Income = income,
Balance = income - expense
});
}
return trends;
}
public async Task<List<TransactionRecord>> GetClassifiedByKeywordsAsync(List<string> keywords, int limit = 10) public async Task<List<TransactionRecord>> GetClassifiedByKeywordsAsync(List<string> keywords, int limit = 10)
{ {
if (keywords == null || keywords.Count == 0) if (keywords.Count == 0)
{ {
return new List<TransactionRecord>(); return [];
} }
var query = FreeSql.Select<TransactionRecord>() var query = FreeSql.Select<TransactionRecord>()
.Where(t => t.Classify != ""); // 只查询已分类的账单 .Where(t => t.Classify != "");
// 构建OR条件Reason包含任意一个关键词
if (keywords.Count > 0) if (keywords.Count > 0)
{ {
query = query.Where(t => keywords.Any(keyword => t.Reason.Contains(keyword))); query = query.Where(t => keywords.Any(keyword => t.Reason.Contains(keyword)));
@@ -635,71 +214,21 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.ToListAsync(); .ToListAsync();
} }
public async Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10) public async Task<List<TransactionRecord>> GetUnconfirmedRecordsAsync()
{ {
if (keywords == null || keywords.Count == 0) return await FreeSql.Select<TransactionRecord>()
{ .Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "")
return new List<(TransactionRecord, double)>(); .OrderByDescending(t => t.OccurredAt)
}
// 查询所有已分类且包含任意关键词的账单
var candidates = await FreeSql.Select<TransactionRecord>()
.Where(t => t.Classify != "")
.Where(t => keywords.Any(keyword => t.Reason.Contains(keyword)))
.ToListAsync(); .ToListAsync();
// 计算每个候选账单的相关度分数
var scoredResults = candidates
.Select(record =>
{
var matchedCount = keywords.Count(keyword => record.Reason.Contains(keyword, StringComparison.OrdinalIgnoreCase));
var matchRate = (double)matchedCount / keywords.Count;
// 额外加分:完全匹配整个摘要(相似度更高)
var exactMatchBonus = keywords.Any(k => record.Reason.Equals(k, StringComparison.OrdinalIgnoreCase)) ? 0.2 : 0.0;
// 长度相似度加分:长度越接近,相关度越高
var avgKeywordLength = keywords.Average(k => k.Length);
var lengthSimilarity = 1.0 - Math.Min(1.0, Math.Abs(record.Reason.Length - avgKeywordLength) / Math.Max(record.Reason.Length, avgKeywordLength));
var lengthBonus = lengthSimilarity * 0.1;
var score = matchRate + exactMatchBonus + lengthBonus;
return (record, score);
})
.Where(x => x.score >= minMatchRate) // 过滤低相关度结果
.OrderByDescending(x => x.score) // 按相关度降序
.ThenByDescending(x => x.record.OccurredAt) // 相同分数时,按时间降序
.Take(limit)
.ToList();
return scoredResults;
} }
public async Task<List<TransactionRecord>> GetCandidatesForOffsetAsync(long currentId, decimal amount, TransactionType currentType) public async Task<int> BatchUpdateByReasonAsync(string reason, TransactionType type, string classify)
{ {
var absAmount = Math.Abs(amount); return await FreeSql.Update<TransactionRecord>()
var minAmount = absAmount - 5; .Set(t => t.Type, type)
var maxAmount = absAmount + 5; .Set(t => t.Classify, classify)
.Where(t => t.Reason == reason)
var currentRecord = await FreeSql.Select<TransactionRecord>() .ExecuteAffrowsAsync();
.Where(t => t.Id == currentId)
.FirstAsync();
if (currentRecord == null)
{
return new List<TransactionRecord>();
}
var list = await FreeSql.Select<TransactionRecord>()
.Where(t => t.Id != currentId)
.Where(t => t.Type != currentType)
.Where(t => Math.Abs(t.Amount) >= minAmount && Math.Abs(t.Amount) <= maxAmount)
.Take(50)
.ToListAsync();
return list.OrderBy(t => Math.Abs(Math.Abs(t.Amount) - absAmount))
.ThenBy(x=> Math.Abs((x.OccurredAt - currentRecord.OccurredAt).TotalSeconds))
.ToList();
} }
public async Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type) public async Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type)
@@ -710,14 +239,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.ExecuteAffrowsAsync(); .ExecuteAffrowsAsync();
} }
public async Task<List<TransactionRecord>> GetUnconfirmedRecordsAsync()
{
return await FreeSql.Select<TransactionRecord>()
.Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "")
.OrderByDescending(t => t.OccurredAt)
.ToListAsync();
}
public async Task<int> ConfirmAllUnconfirmedAsync(long[] ids) public async Task<int> ConfirmAllUnconfirmedAsync(long[] ids)
{ {
return await FreeSql.Update<TransactionRecord>() return await FreeSql.Update<TransactionRecord>()
@@ -729,109 +250,4 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.Where(t => ids.Contains(t.Id)) .Where(t => ids.Contains(t.Id))
.ExecuteAffrowsAsync(); .ExecuteAffrowsAsync();
} }
public async Task<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
DateTime startDate,
DateTime endDate,
TransactionType type,
IEnumerable<string> classifies,
bool groupByMonth = false)
{
var query = FreeSql.Select<TransactionRecord>()
.Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate && t.Type == type);
if (classifies != null && classifies.Any())
{
query = query.Where(t => classifies.Contains(t.Classify));
}
var list = await query.ToListAsync(t => new { t.OccurredAt, t.Amount });
if (groupByMonth)
{
return list
.GroupBy(t => new DateTime(t.OccurredAt.Year, t.OccurredAt.Month, 1))
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
}
else
{
return list
.GroupBy(t => t.OccurredAt.Date)
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
}
}
}
/// <summary>
/// 按Reason分组统计DTO
/// </summary>
public class ReasonGroupDto
{
/// <summary>
/// 交易摘要
/// </summary>
public string Reason { get; set; } = string.Empty;
/// <summary>
/// 该摘要的记录数量
/// </summary>
public int Count { get; set; }
/// <summary>
/// 示例交易类型(该分组中第一条记录的类型)
/// </summary>
public TransactionType SampleType { get; set; }
/// <summary>
/// 示例分类(该分组中第一条记录的分类)
/// </summary>
public string SampleClassify { get; set; } = string.Empty;
/// <summary>
/// 该分组的所有账单ID列表
/// </summary>
public List<long> TransactionIds { get; set; } = new();
/// <summary>
/// 该分组的总金额(绝对值)
/// </summary>
public decimal TotalAmount { get; set; }
}
/// <summary>
/// 月度统计数据
/// </summary>
public class MonthlyStatistics
{
public int Year { get; set; }
public int Month { get; set; }
public decimal TotalExpense { get; set; }
public decimal TotalIncome { get; set; }
public decimal Balance { get; set; }
public int ExpenseCount { get; set; }
public int IncomeCount { get; set; }
public int TotalCount { get; set; }
}
/// <summary>
/// 分类统计数据
/// </summary>
public class CategoryStatistics
{
public string Classify { get; set; } = string.Empty;
public decimal Amount { get; set; }
public int Count { get; set; }
public decimal Percent { get; set; }
}
/// <summary>
/// 趋势统计数据
/// </summary>
public class TrendStatistics
{
public int Year { get; set; }
public int Month { get; set; }
public decimal Expense { get; set; }
public decimal Income { get; set; }
public decimal Balance { get; set; }
} }

View File

@@ -0,0 +1,456 @@
# TransactionRecordRepository 查询语句文档
本文档整理了所有与账单(TransactionRecord)相关的查询语句包括仓储层、服务层中的SQL查询。
## 目录
1. [TransactionRecordRepository 查询方法](#transactionrecordrepository-查询方法)
2. [其他仓储中的账单查询](#其他仓储中的账单查询)
3. [服务层中的SQL查询](#服务层中的sql查询)
4. [总结](#总结)
---
## TransactionRecordRepository 查询方法
### 1. 基础查询
#### 1.1 根据邮件ID和交易时间检查是否存在
```csharp
/// 位置: TransactionRecordRepository.cs:94-99
return await FreeSql.Select<TransactionRecord>()
.Where(t => t.EmailMessageId == emailMessageId && t.OccurredAt == occurredAt)
.FirstAsync();
```
#### 1.2 根据导入编号检查是否存在
```csharp
/// 位置: TransactionRecordRepository.cs:101-106
return await FreeSql.Select<TransactionRecord>()
.Where(t => t.ImportNo == importNo && t.ImportFrom == importFrom)
.FirstAsync();
```
---
### 2. 核心查询构建器
#### 2.1 BuildQuery() 私有方法 - 统一查询构建
```csharp
/// 位置: TransactionRecordRepository.cs:53-92
private ISelect<TransactionRecord> BuildQuery(
int? year = null,
int? month = null,
DateTime? startDate = null,
DateTime? endDate = null,
TransactionType? type = null,
string[]? classifies = null,
string? searchKeyword = null,
string? reason = null)
{
var query = FreeSql.Select<TransactionRecord>();
// 搜索关键词条件Reason/Classify/Card/ImportFrom
query = query.WhereIf(!string.IsNullOrWhiteSpace(searchKeyword),
t => t.Reason.Contains(searchKeyword!) ||
t.Classify.Contains(searchKeyword!) ||
t.Card.Contains(searchKeyword!) ||
t.ImportFrom.Contains(searchKeyword!))
.WhereIf(!string.IsNullOrWhiteSpace(reason),
t => t.Reason == reason);
// 按分类筛选(处理"未分类"特殊情况)
if (classifies is { Length: > 0 })
{
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
query = query.Where(t => filterClassifies.Contains(t.Classify));
}
// 按交易类型筛选
query = query.WhereIf(type.HasValue, t => t.Type == type!.Value);
// 按年月筛选
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);
}
// 按日期范围筛选
query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value)
.WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value);
return query;
}
```
---
### 3. 分页查询与统计
#### 3.1 分页获取交易记录列表
```csharp
/// 位置: TransactionRecordRepository.cs:108-137
var query = BuildQuery(year, month, startDate, endDate, type, classifies, searchKeyword, reason);
// 排序:按金额或按时间
if (sortByAmount)
{
return await query
.OrderByDescending(t => t.Amount)
.OrderByDescending(t => t.Id)
.Page(pageIndex, pageSize)
.ToListAsync();
}
return await query
.OrderByDescending(t => t.OccurredAt)
.OrderByDescending(t => t.Id)
.Page(pageIndex, pageSize)
.ToListAsync();
```
#### 3.2 获取总数(与分页查询条件相同)
```csharp
/// 位置: TransactionRecordRepository.cs:139-151
var query = BuildQuery(year, month, startDate, endDate, type, classifies, searchKeyword, reason);
return await query.CountAsync();
```
#### 3.3 获取所有不同的交易分类
```csharp
/// 位置: TransactionRecordRepository.cs:153-159
return await FreeSql.Select<TransactionRecord>()
.Where(t => !string.IsNullOrEmpty(t.Classify))
.Distinct()
.ToListAsync(t => t.Classify);
```
---
### 4. 按邮件相关查询
#### 4.1 获取指定邮件的交易记录列表
```csharp
/// 位置: TransactionRecordRepository.cs:161-167
return await FreeSql.Select<TransactionRecord>()
.Where(t => t.EmailMessageId == emailMessageId)
.OrderBy(t => t.OccurredAt)
.ToListAsync();
```
#### 4.2 获取指定邮件的交易记录数量
```csharp
/// 位置: TransactionRecordRepository.cs:169-174
return (int)await FreeSql.Select<TransactionRecord>()
.Where(t => t.EmailMessageId == emailMessageId)
.CountAsync();
```
---
### 5. 未分类账单查询
#### 5.1 获取未分类的账单列表
```csharp
/// 位置: TransactionRecordRepository.cs:176-183
return await FreeSql.Select<TransactionRecord>()
.Where(t => string.IsNullOrEmpty(t.Classify))
.OrderByDescending(t => t.OccurredAt)
.Page(1, pageSize)
.ToListAsync();
```
---
### 6. 智能分类相关查询
#### 6.1 根据关键词查询已分类的账单
```csharp
/// 位置: TransactionRecordRepository.cs:185-204
if (keywords.Count == 0)
{
return [];
}
var query = FreeSql.Select<TransactionRecord>()
.Where(t => t.Classify != "");
// 构建OR条件Reason包含任意一个关键词
if (keywords.Count > 0)
{
query = query.Where(t => keywords.Any(keyword => t.Reason.Contains(keyword)));
}
return await query
.OrderByDescending(t => t.OccurredAt)
.Limit(limit)
.ToListAsync();
```
---
### 7. 待确认分类查询
#### 7.1 获取待确认分类的账单列表
```csharp
/// 位置: TransactionRecordRepository.cs:206-212
return await FreeSql.Select<TransactionRecord>()
.Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "")
.OrderByDescending(t => t.OccurredAt)
.ToListAsync();
```
---
### 8. 批量更新操作
#### 8.1 按摘要批量更新交易记录的分类
```csharp
/// 位置: TransactionRecordRepository.cs:214-221
return await FreeSql.Update<TransactionRecord>()
.Set(t => t.Type, type)
.Set(t => t.Classify, classify)
.Where(t => t.Reason == reason)
.ExecuteAffrowsAsync();
```
#### 8.2 更新分类名称
```csharp
/// 位置: TransactionRecordRepository.cs:223-229
return await FreeSql.Update<TransactionRecord>()
.Set(a => a.Classify, newName)
.Where(a => a.Classify == oldName && a.Type == type)
.ExecuteAffrowsAsync();
```
#### 8.3 确认待确认的分类
```csharp
/// 位置: TransactionRecordRepository.cs:231-241
return await FreeSql.Update<TransactionRecord>()
.Set(t => t.Classify == t.UnconfirmedClassify)
.Set(t => t.Type == (t.UnconfirmedType ?? t.Type))
.Set(t => t.UnconfirmedClassify, null)
.Set(t => t.UnconfirmedType, null)
.Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "")
.Where(t => ids.Contains(t.Id))
.ExecuteAffrowsAsync();
```
---
## 其他仓储中的账单查询
### BudgetRepository
#### 1. 获取预算当前金额
```csharp
/// 位置: BudgetRepository.cs:12-33
var query = FreeSql.Select<TransactionRecord>()
.Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate);
if (!string.IsNullOrEmpty(budget.SelectedCategories))
{
var categoryList = budget.SelectedCategories.Split(',');
query = query.Where(t => categoryList.Contains(t.Classify));
}
if (budget.Category == BudgetCategory.Expense)
{
query = query.Where(t => t.Type == TransactionType.Expense);
}
else if (budget.Category == BudgetCategory.Income)
{
query = query.Where(t => t.Type == TransactionType.Income);
}
return await query.SumAsync(t => t.Amount);
```
---
### TransactionCategoryRepository
#### 1. 检查分类是否被使用
```csharp
/// 位置: TransactionCategoryRepository.cs:53-63
var count = await FreeSql.Select<TransactionRecord>()
.Where(r => r.Classify == category.Name && r.Type == category.Type)
.CountAsync();
return count > 0;
```
---
## 服务层中的SQL查询
### SmartHandleService
#### 1. 智能分析账单 - 执行AI生成的SQL
```csharp
/// 位置: SmartHandleService.cs:351
queryResults = await transactionRepository.ExecuteDynamicSqlAsync(sqlText);
```
**说明**: 此方法接收AI生成的SQL语句并执行SQL内容由AI根据用户问题动态生成例如
```sql
SELECT
COUNT(*) AS TransactionCount,
SUM(ABS(Amount)) AS TotalAmount,
Type,
Classify
FROM TransactionRecord
WHERE OccurredAt >= '2025-01-01'
AND OccurredAt < '2026-01-01'
GROUP BY Type, Classify
ORDER BY TotalAmount DESC
```
---
### BudgetService
#### 1. 获取归档摘要 - 年度交易统计
```csharp
/// 位置: BudgetService.cs:239-252
var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
$"""
SELECT
COUNT(*) AS TransactionCount,
SUM(ABS(Amount)) AS TotalAmount,
Type,
Classify
FROM TransactionRecord
WHERE OccurredAt >= '{year}-01-01'
AND OccurredAt < '{year + 1}-01-01'
GROUP BY Type, Classify
ORDER BY TotalAmount DESC
"""
);
```
#### 2. 获取归档摘要 - 月度交易统计
```csharp
/// 位置: BudgetService.cs:254-267
var monthYear = new DateTime(year, month, 1).AddMonths(1);
var monthTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
$"""
SELECT
COUNT(*) AS TransactionCount,
SUM(ABS(Amount)) AS TotalAmount,
Type,
Classify
FROM TransactionRecord
WHERE OccurredAt >= '{year}-{month:00}-01'
AND OccurredAt < '{monthYear:yyyy-MM-dd}'
GROUP BY Type, Classify
ORDER BY TotalAmount DESC
"""
);
```
---
### BudgetSavingsService
#### 1. 获取按分类分组的交易金额(用于存款预算计算)
```csharp
/// 位置: BudgetSavingsService.cs:62-65
var transactionClassify = await transactionsRepository.GetAmountGroupByClassifyAsync(
new DateTime(year, month, 1),
new DateTime(year, month, 1).AddMonths(1)
);
```
---
## 总结
### 查询方法分类
| 分类 | 方法数 | 说明 |
|------|--------|------|
| 基础查询 | 2 | 检查记录是否存在(去重) |
| 核心构建器 | 1 | BuildQuery() 私有方法,统一查询逻辑 |
| 分页查询 | 2 | 分页列表 + 总数统计 |
| 分类查询 | 1 | 获取所有不同分类 |
| 邮件相关 | 2 | 按邮件ID查询列表和数量 |
| 未分类查询 | 1 | 获取未分类账单列表 |
| 智能分类 | 1 | 关键词匹配查询 |
| 待确认分类 | 1 | 获取待确认账单列表 |
| 批量更新 | 3 | 批量更新分类和确认操作 |
| 其他仓储查询 | 2 | 预算/分类仓储中的账单查询 |
| 服务层SQL | 3 | AI生成SQL + 归档统计 |
### 关键发现
1. **简化的架构**新实现移除了复杂的统计方法专注于核心的CRUD操作和查询功能。
2. **统一的查询构建**`BuildQuery()` 私有方法第53-92行`QueryAsync()``CountAsync()` 共享使用,确保查询逻辑一致性。
3. **去重检查**`ExistsByEmailMessageIdAsync()``ExistsByImportNoAsync()` 用于防止重复导入。
4. **灵活的查询条件**:支持按年月、日期范围、交易类型、分类、关键词等多维度筛选。
5. **批量操作优化**:提供批量更新分类、确认待确认记录等高效操作。
6. **服务层SQL保持不变**AI生成SQL和归档统计等高级查询功能仍然通过 `ExecuteDynamicSqlAsync()` 实现。
### SQL查询模式
所有SQL查询都遵循以下模式
```sql
SELECT [] FROM TransactionRecord
WHERE []
ORDER BY []
LIMIT []
```
常用查询条件:
- `EmailMessageId == ? AND OccurredAt == ?` - 精确匹配去重
- `ImportNo == ? AND ImportFrom == ?` - 导入记录去重
- `Classify != ""` - 已分类记录
- `Classify == "" OR Classify IS NULL` - 未分类记录
- `UnconfirmedClassify != ""` - 待确认记录
- `Reason.Contains(?) OR Classify.Contains(?)` - 关键词搜索
### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| Id | bigint | 主键 |
| Card | nvarchar | 卡号 |
| Reason | nvarchar | 交易原因/摘要 |
| Amount | decimal | 交易金额(支出为负数,收入为正数) |
| OccurredAt | datetime | 交易发生时间 |
| Type | int | 交易类型0=支出, 1=收入, 2=不计入收支) |
| Classify | nvarchar | 交易分类(空字符串表示未分类) |
| EmailMessageId | bigint | 关联邮件ID |
| ImportNo | nvarchar | 导入编号 |
| ImportFrom | nvarchar | 导入来源 |
| UnconfirmedClassify | nvarchar | 待确认分类 |
| UnconfirmedType | int? | 待确认类型 |
### 接口方法总览
**ITransactionRecordRepository 接口定义17个方法**
1. `ExistsByEmailMessageIdAsync()` - 邮件去重检查
2. `ExistsByImportNoAsync()` - 导入去重检查
3. `QueryAsync()` - 分页查询(支持多维度筛选)
4. `CountAsync()` - 总数统计与QueryAsync条件相同
5. `GetDistinctClassifyAsync()` - 获取所有分类
6. `GetByEmailIdAsync()` - 按邮件ID查询记录
7. `GetCountByEmailIdAsync()` - 按邮件ID统计数量
8. `GetUnclassifiedAsync()` - 获取未分类记录
9. `GetClassifiedByKeywordsAsync()` - 关键词匹配查询
10. `GetUnconfirmedRecordsAsync()` - 获取待确认记录
11. `BatchUpdateByReasonAsync()` - 按摘要批量更新
12. `UpdateCategoryNameAsync()` - 更新分类名称
13. `ConfirmAllUnconfirmedAsync()` - 确认待确认记录
**私有辅助方法:**
- `BuildQuery()` - 统一查询构建器被QueryAsync和CountAsync使用

55
Service/AGENTS.md Normal file
View File

@@ -0,0 +1,55 @@
# SERVICE LAYER KNOWLEDGE BASE
**Generated:** 2026-01-28
**Parent:** EmailBill/AGENTS.md
## OVERVIEW
Business logic layer with job scheduling, email processing, and application services.
## STRUCTURE
```
Service/
├── GlobalUsings.cs # Common imports
├── Jobs/ # Background jobs
│ ├── BudgetArchiveJob.cs # Budget archiving
│ ├── DbBackupJob.cs # Database backups
│ ├── EmailSyncJob.cs # Email synchronization
│ └── PeriodicBillJob.cs # Periodic bill processing
├── EmailServices/ # Email processing
│ ├── EmailHandleService.cs # Email handling logic
│ ├── EmailFetchService.cs # Email fetching
│ ├── EmailSyncService.cs # Email synchronization
│ └── EmailParse/ # Email parsing services
├── AppSettingModel/ # Configuration models
├── Budget/ # Budget services
└── [Various service classes] # Core business services
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Background jobs | Jobs/ | Scheduled tasks, cron patterns |
| Email processing | EmailServices/ | Email parsing, handling, sync |
| Budget logic | Budget/ | Budget calculations, stats |
| Configuration | AppSettingModel/ | Settings models, validation |
| Core services | *.cs | Main business logic |
## CONVENTIONS
- Service classes end with "Service" suffix
- Jobs inherit from appropriate base job classes
- Use IDateTimeProvider for time operations
- Async/await for I/O operations
- Dependency injection via constructor
## ANTI-PATTERNS (THIS LAYER)
- Never access database directly (use repositories)
- Don't return domain entities to controllers (use DTOs)
- Avoid long-running operations in main thread
- No hardcoded configuration values
- Don't mix service responsibilities
## UNIQUE STYLES
- Email parsing with multiple format handlers
- Background job patterns with error handling
- Configuration models with validation attributes
- Service composition patterns

View File

@@ -1,25 +1,25 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
namespace Service; namespace Service.AI;
public interface IOpenAiService public interface IOpenAiService
{ {
Task<string?> ChatAsync(string systemPrompt, string userPrompt); Task<string?> ChatAsync(string systemPrompt, string userPrompt, int timeoutSeconds = 15);
Task<string?> ChatAsync(string prompt); Task<string?> ChatAsync(string prompt, int timeoutSeconds = 15);
IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt); IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt);
IAsyncEnumerable<string> ChatStreamAsync(string prompt); IAsyncEnumerable<string> ChatStreamAsync(string prompt);
} }
public class OpenAiService( public class OpenAiService(
IOptions<AISettings> aiSettings, IOptions<AiSettings> aiSettings,
ILogger<OpenAiService> logger ILogger<OpenAiService> logger
) : IOpenAiService ) : IOpenAiService
{ {
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt) public async Task<string?> ChatAsync(string systemPrompt, string userPrompt, int timeoutSeconds = 15)
{ {
var cfg = aiSettings.Value; var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) || if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) || string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model)) string.IsNullOrWhiteSpace(cfg.Model))
{ {
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI"); logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
@@ -27,7 +27,7 @@ public class OpenAiService(
} }
using var http = new HttpClient(); using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(15); http.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new var payload = new
@@ -44,7 +44,7 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions"; var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
try try
{ {
using var resp = await http.PostAsync(url, content); using var resp = await http.PostAsync(url, content);
@@ -72,11 +72,11 @@ public class OpenAiService(
} }
} }
public async Task<string?> ChatAsync(string prompt) public async Task<string?> ChatAsync(string prompt, int timeoutSeconds = 15)
{ {
var cfg = aiSettings.Value; var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) || if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) || string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model)) string.IsNullOrWhiteSpace(cfg.Model))
{ {
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI"); logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
@@ -84,7 +84,7 @@ public class OpenAiService(
} }
using var http = new HttpClient(); using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(60 * 5); http.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new var payload = new
@@ -100,7 +100,7 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions"; var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
try try
{ {
using var resp = await http.PostAsync(url, content); using var resp = await http.PostAsync(url, content);
@@ -131,8 +131,8 @@ public class OpenAiService(
public async IAsyncEnumerable<string> ChatStreamAsync(string prompt) public async IAsyncEnumerable<string> ChatStreamAsync(string prompt)
{ {
var cfg = aiSettings.Value; var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) || if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) || string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model)) string.IsNullOrWhiteSpace(cfg.Model))
{ {
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI"); logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
@@ -157,13 +157,11 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions"; var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, url) using var request = new HttpRequestMessage(HttpMethod.Post, url);
{ request.Content = content;
Content = content
};
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
var err = await resp.Content.ReadAsStringAsync(); var err = await resp.Content.ReadAsStringAsync();
@@ -203,8 +201,8 @@ public class OpenAiService(
public async IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt) public async IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt)
{ {
var cfg = aiSettings.Value; var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) || if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) || string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model)) string.IsNullOrWhiteSpace(cfg.Model))
{ {
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI"); logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
@@ -230,14 +228,12 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions"; var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
// 使用 SendAsync 来支持 HttpCompletionOption // 使用 SendAsync 来支持 HttpCompletionOption
using var request = new HttpRequestMessage(HttpMethod.Post, url) using var request = new HttpRequestMessage(HttpMethod.Post, url);
{ request.Content = content;
Content = content
};
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
var err = await resp.Content.ReadAsStringAsync(); var err = await resp.Content.ReadAsStringAsync();

View File

@@ -1,4 +1,6 @@
namespace Service; using Service.Transaction;
namespace Service.AI;
public interface ISmartHandleService public interface ISmartHandleService
{ {
@@ -11,6 +13,7 @@ public interface ISmartHandleService
public class SmartHandleService( public class SmartHandleService(
ITransactionRecordRepository transactionRepository, ITransactionRecordRepository transactionRepository,
ITransactionStatisticsService transactionStatisticsService,
ITextSegmentService textSegmentService, ITextSegmentService textSegmentService,
ILogger<SmartHandleService> logger, ILogger<SmartHandleService> logger,
ITransactionCategoryRepository categoryRepository, ITransactionCategoryRepository categoryRepository,
@@ -61,7 +64,7 @@ public class SmartHandleService(
{ {
// 查询包含这些关键词且已分类的账单(带相关度评分) // 查询包含这些关键词且已分类的账单(带相关度评分)
// minMatchRate=0.4 表示至少匹配40%的关键词才被认为是相似的 // minMatchRate=0.4 表示至少匹配40%的关键词才被认为是相似的
var similarClassifiedWithScore = await transactionRepository.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.4, limit: 10); var similarClassifiedWithScore = await transactionStatisticsService.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.4, limit: 10);
if (similarClassifiedWithScore.Count > 0) if (similarClassifiedWithScore.Count > 0)
{ {
@@ -143,7 +146,7 @@ public class SmartHandleService(
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单")); chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
var classifyResults = new List<(string Reason, string Classify, TransactionType Type)>(); var classifyResults = new List<(string Reason, string Classify, TransactionType Type)>();
var sendedIds = new HashSet<long>(); var sentIds = new HashSet<long>();
// 将流解析逻辑提取为本地函数以减少嵌套 // 将流解析逻辑提取为本地函数以减少嵌套
void HandleResult(GroupClassifyResult? result) void HandleResult(GroupClassifyResult? result)
@@ -154,16 +157,18 @@ public class SmartHandleService(
if (group == null) return; if (group == null) return;
foreach (var id in group.Ids) foreach (var id in group.Ids)
{ {
if (sendedIds.Add(id)) if (!sentIds.Add(id))
{ {
var resultJson = JsonSerializer.Serialize(new continue;
{
id,
result.Classify,
result.Type
});
chunkAction(("data", resultJson));
} }
var resultJson = JsonSerializer.Serialize(new
{
id,
result.Classify,
result.Type
});
chunkAction(("data", resultJson));
} }
} }
@@ -193,7 +198,7 @@ public class SmartHandleService(
} }
catch (Exception exArr) catch (Exception exArr)
{ {
logger.LogDebug(exArr, "按数组解析AI返回失败回退到逐对象解析。预览: {Preview}", arrJson?.Length > 200 ? arrJson.Substring(0, 200) + "..." : arrJson); logger.LogDebug(exArr, "按数组解析AI返回失败回退到逐对象解析。预览: {Preview}", arrJson.Length > 200 ? arrJson.Substring(0, 200) + "..." : arrJson);
} }
} }
} }
@@ -336,7 +341,7 @@ public class SmartHandleService(
{ {
content = $""" content = $"""
<pre style="max-height: 80px; font-size: 8px; overflow-y: auto; padding: 8px; border: 1px solid #3c3c3c"> <pre style="max-height: 80px; font-size: 8px; overflow-y: auto; padding: 8px; border: 1px solid #3c3c3c">
{System.Net.WebUtility.HtmlEncode(sqlText)} {WebUtility.HtmlEncode(sqlText)}
</pre> </pre>
""" """
}) })
@@ -361,7 +366,7 @@ public class SmartHandleService(
var dataJson = JsonSerializer.Serialize(queryResults, new JsonSerializerOptions var dataJson = JsonSerializer.Serialize(queryResults, new JsonSerializerOptions
{ {
WriteIndented = true, WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
}); });
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt"); var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
@@ -429,7 +434,6 @@ public class SmartHandleService(
{ {
// 获取所有分类 // 获取所有分类
var categories = await categoryRepository.GetAllAsync(); var categories = await categoryRepository.GetAllAsync();
var categoryList = string.Join("、", categories.Select(c => $"{GetTypeName(c.Type)}-{c.Name}"));
// 构建分类信息 // 构建分类信息
var categoryInfo = new StringBuilder(); var categoryInfo = new StringBuilder();
@@ -490,8 +494,8 @@ public class SmartHandleService(
/// </summary> /// </summary>
private static int FindMatchingBrace(string str, int startPos) private static int FindMatchingBrace(string str, int startPos)
{ {
int braceCount = 0; var braceCount = 0;
for (int i = startPos; i < str.Length; i++) for (var i = startPos; i < str.Length; i++)
{ {
if (str[i] == '{') braceCount++; if (str[i] == '{') braceCount++;
else if (str[i] == '}') else if (str[i] == '}')
@@ -542,13 +546,13 @@ public class SmartHandleService(
public record GroupClassifyResult public record GroupClassifyResult
{ {
[JsonPropertyName("reason")] [JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty; public string Reason { get; init; } = string.Empty;
[JsonPropertyName("classify")] [JsonPropertyName("classify")]
public string? Classify { get; set; } public string? Classify { get; init; }
[JsonPropertyName("type")] [JsonPropertyName("type")]
public TransactionType Type { get; set; } public TransactionType Type { get; init; }
} }
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type); public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);

View File

@@ -1,8 +1,7 @@
namespace Service; using JiebaNet.Analyser;
using JiebaNet.Segmenter; using JiebaNet.Segmenter;
using JiebaNet.Analyser;
using Microsoft.Extensions.Logging; namespace Service.AI;
/// <summary> /// <summary>
/// 文本分词服务接口 /// 文本分词服务接口
@@ -39,7 +38,7 @@ public class TextSegmentService : ITextSegmentService
_logger = logger; _logger = logger;
_segmenter = new JiebaSegmenter(); _segmenter = new JiebaSegmenter();
_extractor = new TfidfExtractor(); _extractor = new TfidfExtractor();
// 仅添加JiebaNet词典中可能缺失的特定业务词汇 // 仅添加JiebaNet词典中可能缺失的特定业务词汇
AddCustomWords(); AddCustomWords();
} }
@@ -78,7 +77,7 @@ public class TextSegmentService : ITextSegmentService
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
return new List<string>(); return [];
} }
try try
@@ -110,7 +109,7 @@ public class TextSegmentService : ITextSegmentService
filteredKeywords.Add(text.Length > 10 ? text.Substring(0, 10) : text); filteredKeywords.Add(text.Length > 10 ? text.Substring(0, 10) : text);
} }
_logger.LogDebug("从文本 '{Text}' 中提取关键词: {Keywords}", _logger.LogDebug("从文本 '{Text}' 中提取关键词: {Keywords}",
text, string.Join(", ", filteredKeywords)); text, string.Join(", ", filteredKeywords));
return filteredKeywords; return filteredKeywords;
@@ -119,7 +118,7 @@ public class TextSegmentService : ITextSegmentService
{ {
_logger.LogError(ex, "提取关键词失败,文本: {Text}", text); _logger.LogError(ex, "提取关键词失败,文本: {Text}", text);
// 降级处理:返回原文 // 降级处理:返回原文
return new List<string> { text.Length > 10 ? text.Substring(0, 10) : text }; return [text.Length > 10 ? text.Substring(0, 10) : text];
} }
} }
@@ -127,7 +126,7 @@ public class TextSegmentService : ITextSegmentService
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
return new List<string>(); return [];
} }
try try
@@ -146,7 +145,7 @@ public class TextSegmentService : ITextSegmentService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "分词失败,文本: {Text}", text); _logger.LogError(ex, "分词失败,文本: {Text}", text);
return new List<string> { text }; return [text];
} }
} }
} }

View File

@@ -1,6 +1,6 @@
namespace Service.AppSettingModel; namespace Service.AppSettingModel;
public class AISettings public class AiSettings
{ {
public string Endpoint { get; set; } = string.Empty; public string Endpoint { get; set; } = string.Empty;
public string Key { get; set; } = string.Empty; public string Key { get; set; } = string.Empty;

View File

@@ -0,0 +1,929 @@
using Service.Transaction;
namespace Service.Budget;
public interface IBudgetSavingsService
{
Task<BudgetResult> GetSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable<BudgetRecord>? existingBudgets = null);
}
public class BudgetSavingsService(
IBudgetRepository budgetRepository,
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionStatisticsService transactionStatisticsService,
IConfigService configService,
IDateTimeProvider dateTimeProvider
) : IBudgetSavingsService
{
public async Task<BudgetResult> GetSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable<BudgetRecord>? existingBudgets = null)
{
var budgets = existingBudgets;
if (existingBudgets == null)
{
budgets = await budgetRepository.GetAllAsync();
}
if (budgets == null)
{
throw new InvalidOperationException("No budgets found.");
}
budgets = budgets
// 排序顺序 1.硬性预算 2.月度->年度 3.实际金额倒叙
.OrderBy(b => b.IsMandatoryExpense)
.ThenBy(b => b.Type)
.ThenByDescending(b => b.Limit);
var year = referenceDate?.Year ?? dateTimeProvider.Now.Year;
var month = referenceDate?.Month ?? dateTimeProvider.Now.Month;
if (periodType == BudgetPeriodType.Month)
{
return await GetForMonthAsync(budgets, year, month);
}
else if (periodType == BudgetPeriodType.Year)
{
return await GetForYearAsync(budgets, year);
}
throw new NotSupportedException($"Period type {periodType} is not supported.");
}
private async Task<BudgetResult> GetForMonthAsync(
IEnumerable<BudgetRecord> budgets,
int year,
int month)
{
var transactionClassify = await transactionStatisticsService.GetAmountGroupByClassifyAsync(
new DateTime(year, month, 1),
new DateTime(year, month, 1).AddMonths(1)
);
var monthlyIncomeItems = new List<(string name, decimal limit, decimal current, bool isMandatory)>();
var monthlyExpenseItems = new List<(string name, decimal limit, decimal current, bool isMandatory)>();
var monthlyBudgets = budgets
.Where(b => b.Type == BudgetPeriodType.Month);
foreach (var budget in monthlyBudgets)
{
var classifyList = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
decimal currentAmount = 0;
var transactionType = budget.Category switch
{
BudgetCategory.Income => TransactionType.Income,
BudgetCategory.Expense => TransactionType.Expense,
_ => throw new NotSupportedException($"Budget category {budget.Category} is not supported.")
};
foreach (var classify in classifyList)
{
// 获取分类+收入支出类型一致的金额
if (transactionClassify.TryGetValue((classify, transactionType), out var amount))
{
currentAmount += amount;
}
}
// 硬性预算 如果实际发生金额小于 应(总天数/实际天数)发生金额
// 直接取应发生金额(为了预算的准确性)
if (budget.IsMandatoryExpense && currentAmount == 0)
{
currentAmount = budget.Limit / DateTime.DaysInMonth(year, month) * dateTimeProvider.Now.Day;
}
if (budget.Category == BudgetCategory.Income)
{
monthlyIncomeItems.Add((
name: budget.Name,
limit: budget.Limit,
current: currentAmount,
isMandatory: budget.IsMandatoryExpense
));
}
else if (budget.Category == BudgetCategory.Expense)
{
monthlyExpenseItems.Add((
name: budget.Name,
limit: budget.Limit,
current: currentAmount,
isMandatory: budget.IsMandatoryExpense
));
}
}
var yearlyIncomeItems = new List<(string name, decimal limit, decimal current)>();
var yearlyExpenseItems = new List<(string name, decimal limit, decimal current)>();
var yearlyBudgets = budgets
.Where(b => b.Type == BudgetPeriodType.Year);
// 只需要考虑实际发生在本月的年度预算 因为他会影响到月度的结余情况
foreach (var budget in yearlyBudgets)
{
var classifyList = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
decimal currentAmount = 0;
var transactionType = budget.Category switch
{
BudgetCategory.Income => TransactionType.Income,
BudgetCategory.Expense => TransactionType.Expense,
_ => throw new NotSupportedException($"Budget category {budget.Category} is not supported.")
};
foreach (var classify in classifyList)
{
// 获取分类+收入支出类型一致的金额
if (transactionClassify.TryGetValue((classify, transactionType), out var amount))
{
currentAmount += amount;
}
}
if (currentAmount == 0)
{
continue;
}
if (budget.Category == BudgetCategory.Income)
{
yearlyIncomeItems.Add((
name: budget.Name,
limit: budget.Limit,
current: currentAmount
));
}
else if (budget.Category == BudgetCategory.Expense)
{
yearlyExpenseItems.Add((
name: budget.Name,
limit: budget.Limit,
current: currentAmount
));
}
}
var description = new StringBuilder();
#region
description.AppendLine("<h3>月度预算收入明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in monthlyIncomeItems)
{
description.AppendLine($"""
<tr>
<td>{item.name}</td>
<td>{item.limit:N0}</td>
<td>{(item.isMandatory ? "" : "")}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
description.AppendLine($"""
<p>
收入合计:
<span class='income-value'>
<strong>{monthlyIncomeItems.Sum(item => item.limit):N0}</strong>
</span>
</p>
""");
description.AppendLine("<h3>月度预算支出明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in monthlyExpenseItems)
{
description.AppendLine($"""
<tr>
<td>{item.name}</td>
<td>{item.limit:N0}</td>
<td>{(item.isMandatory ? "" : "")}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
description.AppendLine($"""
<p>
支出合计:
<span class='expense-value'>
<strong>{monthlyExpenseItems.Sum(item => item.limit):N0}</strong>
</span>
</p>
""");
#endregion
#region
if (yearlyIncomeItems.Any())
{
description.AppendLine("<h3>年度收入预算(发生在本月)</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in yearlyIncomeItems)
{
description.AppendLine($"""
<tr>
<td>{item.name}</td>
<td>{(item.limit == 0 ? "" : item.limit.ToString("N0"))}</td>
<td><span class='income-value'>{item.current:N0}</span></td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
description.AppendLine($"""
<p>
收入合计:
<span class='income-value'>
<strong>{yearlyIncomeItems.Sum(item => item.current):N0}</strong>
</span>
</p>
""");
}
if (yearlyExpenseItems.Any())
{
description.AppendLine("<h3>年度支出预算(发生在本月)</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in yearlyExpenseItems)
{
description.AppendLine($"""
<tr>
<td>{item.name}</td>
<td>{(item.limit == 0 ? "" : item.limit.ToString("N0"))}</td>
<td><span class='expense-value'>{item.current:N0}</span></td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
description.AppendLine($"""
<p>
支出合计:
<span class='expense-value'>
<strong>{yearlyExpenseItems.Sum(item => item.current):N0}</strong>
</span>
</p>
""");
}
#endregion
#region
description.AppendLine("<h3>存款计划结论</h3>");
var plannedIncome = monthlyIncomeItems.Sum(item => item.limit == 0 ? item.current : item.limit) + yearlyIncomeItems.Sum(item => item.current);
var plannedExpense = monthlyExpenseItems.Sum(item => item.limit == 0 ? item.current : item.limit) + yearlyExpenseItems.Sum(item => item.current);
var expectedSavings = plannedIncome - plannedExpense;
description.AppendLine($"""
<p>
计划存款:
<span class='income-value'>
<strong>{expectedSavings:N0}</strong>
</span>
=
</p>
<p>
&nbsp;&nbsp;&nbsp;&nbsp;
计划收入:
<span class='income-value'>
<strong>{monthlyIncomeItems.Sum(item => item.limit):N0}</strong>
</span>
</p>
""");
if (yearlyIncomeItems.Count > 0)
{
description.AppendLine($"""
<p>
&nbsp;&nbsp;&nbsp;&nbsp;
+ 本月发生的年度预算收入:
<span class='income-value'>
<strong>{yearlyIncomeItems.Sum(item => item.current):N0}</strong>
</span>
</p>
""");
}
description.AppendLine($"""
<p>
&nbsp;&nbsp;&nbsp;&nbsp;
- 计划支出:
<span class='expense-value'>
<strong>{monthlyExpenseItems.Sum(item => item.limit):N0}</strong>
</span>
""");
if (yearlyExpenseItems.Count > 0)
{
description.AppendLine($"""
<p>
&nbsp;&nbsp;&nbsp;&nbsp;
- 本月发生的年度预算支出:
<span class='expense-value'>
<strong>{yearlyExpenseItems.Sum(item => item.current):N0}</strong>
</span>
</p>
""");
}
description.AppendLine($"""
</p>
""");
#endregion
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
var currentActual = 0m;
if (!string.IsNullOrEmpty(savingsCategories))
{
var cats = new HashSet<string>(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries));
foreach (var kvp in transactionClassify)
{
if (cats.Contains(kvp.Key.Item1))
{
currentActual += kvp.Value;
}
}
}
var record = new BudgetRecord
{
Id = -2,
Name = "月度存款计划",
Type = BudgetPeriodType.Month,
Limit = expectedSavings,
Category = BudgetCategory.Savings,
SelectedCategories = savingsCategories,
StartDate = new DateTime(year, month, 1),
NoLimit = false,
IsMandatoryExpense = false,
CreateTime = dateTimeProvider.Now,
UpdateTime = dateTimeProvider.Now
};
return BudgetResult.FromEntity(
record,
currentActual,
new DateTime(year, month, 1),
description.ToString()
);
}
private async Task<BudgetResult> GetForYearAsync(
IEnumerable<BudgetRecord> budgets,
int year)
{
// 因为非当前月份的读取归档数据,这边依然是读取当前月份的数据
var currentMonth = dateTimeProvider.Now.Month;
var transactionClassify = await transactionStatisticsService.GetAmountGroupByClassifyAsync(
new DateTime(year, currentMonth, 1),
new DateTime(year, currentMonth, 1).AddMonths(1)
);
var currentMonthlyIncomeItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
var currentYearlyIncomeItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
var currentMonthlyExpenseItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
var currentYearlyExpenseItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
// 归档的预算收入支出明细
var archiveIncomeItems = new List<(long id, string name, int[] months, decimal limit, decimal current)>();
var archiveExpenseItems = new List<(long id, string name, int[] months, decimal limit, decimal current)>();
var archiveSavingsItems = new List<(long id, string name, int[] months, decimal limit, decimal current)>();
// 获取归档数据
var archives = await budgetArchiveRepository.GetArchivesByYearAsync(year);
var archiveBudgetGroups = archives
.SelectMany(a => a.Content.Select(x => (a.Month, Archive: x)))
.Where(b => b.Archive.Type == BudgetPeriodType.Month) // 因为本来就是当前年度预算的生成 ,归档无需关心年度, 以最新地为准即可
.GroupBy(b => (b.Archive.Id, b.Archive.Limit));
foreach (var archiveBudgetGroup in archiveBudgetGroups)
{
var (_, archive) = archiveBudgetGroup.First();
var archiveItems = archive.Category switch
{
BudgetCategory.Income => archiveIncomeItems,
BudgetCategory.Expense => archiveExpenseItems,
BudgetCategory.Savings => archiveSavingsItems,
_ => throw new NotSupportedException($"Category {archive.Category} is not supported.")
};
archiveItems.Add((
id: archiveBudgetGroup.Key.Id,
name: archive.Name,
months: archiveBudgetGroup.Select(x => x.Month).OrderBy(m => m).ToArray(),
limit: archiveBudgetGroup.Key.Limit,
current: archiveBudgetGroup.Sum(x => x.Archive.Actual)
));
}
// 处理当月最新地没有归档的预算
foreach (var budget in budgets)
{
var currentAmount = 0m;
var classifyList = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
var transactionType = budget.Category switch
{
BudgetCategory.Income => TransactionType.Income,
BudgetCategory.Expense => TransactionType.Expense,
_ => throw new NotSupportedException($"Budget category {budget.Category} is not supported.")
};
foreach (var classify in classifyList)
{
// 获取分类+收入支出类型一致的金额
if (transactionClassify.TryGetValue((classify, transactionType), out var amount))
{
currentAmount += amount;
}
}
// 硬性预算 如果实际发生金额小于 应(总天数/实际天数)发生金额
// 直接取应发生金额(为了预算的准确性)
if (budget.IsMandatoryExpense && currentAmount == 0)
{
currentAmount = budget.IsMandatoryExpense && currentAmount == 0
? budget.Limit / (DateTime.IsLeapYear(year) ? 366 : 365) * dateTimeProvider.Now.DayOfYear
: budget.Limit / DateTime.DaysInMonth(year, currentMonth) * dateTimeProvider.Now.Day;
}
AddOrIncCurrentItem(
budget.Id,
budget.Type,
budget.Category,
budget.Name,
budget.Limit,
budget.Type == BudgetPeriodType.Year
? 1
: 12 - currentMonth + 1,
currentAmount,
budget.IsMandatoryExpense
);
}
var description = new StringBuilder();
#region
var archiveIncomeDiff = 0m;
if (archiveIncomeItems.Any())
{
description.AppendLine("<h3>已归档收入明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
</tbody>
""");
// 已归档的收入
foreach (var (_, name, months, limit, current) in archiveIncomeItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "" : limit.ToString("N0"))}</td>
<td>{FormatMonths(months)}</td>
<td>{limit * months.Length:N0}</td>
<td><span class='income-value'>{current:N0}</span></td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
archiveIncomeDiff = archiveIncomeItems.Sum(i => i.current) - archiveIncomeItems.Sum(i => i.limit * i.months.Length);
description.AppendLine($"""
<p>
<span class="highlight">已归档收入总结: </span>
{(archiveIncomeDiff > 0 ? "超额收入" : "未达预期")}:
<span class='{(archiveIncomeDiff > 0 ? "income-value" : "expense-value")}'>
<strong>{archiveIncomeDiff:N0}</strong>
</span>
=
<span class='income-value'>
<strong>{archiveIncomeItems.Sum(i => i.limit * i.months.Length):N0}</strong>
</span>
-
:
<span class='income-value'>
<strong>{archiveIncomeItems.Sum(i => i.current):N0}</strong>
</span>
</p>
""");
}
#endregion
#region
description.AppendLine("<h3>预算收入明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>/</th>
<th></th>
</tr>
</thead>
<tbody>
""");
// 当前预算
foreach (var (_, name, limit, factor, _, _) in currentMonthlyIncomeItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "" : limit.ToString("N0"))}</td>
<td>{FormatMonthsByFactor(factor)}</td>
<td>{(limit == 0 ? "不限额" : (limit * factor).ToString("N0"))}</td>
</tr>
""");
}
// 年预算
foreach (var (_, name, limit, _, _, _) in currentYearlyIncomeItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "" : limit.ToString("N0"))}</td>
<td>{year}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
description.AppendLine($"""
<p>
预算收入合计:
<span class='expense-value'>
<strong>
{currentMonthlyIncomeItems.Sum(i => i.limit * i.factor)
+ currentYearlyIncomeItems.Sum(i => i.limit):N0}
</strong>
</span>
</p>
""");
#endregion
#region
var archiveExpenseDiff = 0m;
if (archiveExpenseItems.Any())
{
description.AppendLine("<h3>已归档支出明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
// 已归档的支出
foreach (var (_, name, months, limit, current) in archiveExpenseItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "" : limit.ToString("N0"))}</td>
<td>{FormatMonths(months)}</td>
<td>{limit * months.Length:N0}</td>
<td><span class='expense-value'>{current:N0}</span></td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
archiveExpenseDiff = archiveExpenseItems.Sum(i => i.limit * i.months.Length) - archiveExpenseItems.Sum(i => i.current);
description.AppendLine($"""
<p>
<span class="highlight">已归档支出总结: </span>
{(archiveExpenseDiff > 0 ? "节省支出" : "超支")}:
<span class='{(archiveExpenseDiff > 0 ? "income-value" : "expense-value")}'>
<strong>{archiveExpenseDiff:N0}</strong>
</span>
=
<span class='expense-value'>
<strong>{archiveExpenseItems.Sum(i => i.limit * i.months.Length):N0}</strong>
</span>
- :
<span class='expense-value'>
<strong>{archiveExpenseItems.Sum(i => i.current):N0}</strong>
</span>
</p>
""");
}
#endregion
#region
var archiveSavingsDiff = 0m;
if (archiveSavingsItems.Any())
{
description.AppendLine("<h3>已归档存款明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
// 已归档的存款
foreach (var (_, name, months, limit, current) in archiveSavingsItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "" : limit.ToString("N0"))}</td>
<td>{FormatMonths(months)}</td>
<td>{limit * months.Length:N0}</td>
<td><span class='income-value'>{current:N0}</span></td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
archiveSavingsDiff = archiveSavingsItems.Sum(i => i.current) - archiveSavingsItems.Sum(i => i.limit * i.months.Length);
description.AppendLine($"""
<p>
<span class="highlight">已归档存款总结: </span>
{(archiveSavingsDiff > 0 ? "超额存款" : "未达预期")}:
<span class='{(archiveSavingsDiff > 0 ? "income-value" : "expense-value")}'>
<strong>{archiveSavingsDiff:N0}</strong>
</span>
=
:
<span class='income-value'>
<strong>{archiveSavingsItems.Sum(i => i.current):N0}</strong>
</span>
-
:
<span class='income-value'>
<strong>{archiveSavingsItems.Sum(i => i.limit * i.months.Length):N0}</strong>
</span>
</p>
""");
}
#endregion
#region
description.AppendLine("<h3>预算支出明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>/</th>
<th></th>
</tr>
</thead>
<tbody>
""");
// 未来月预算
foreach (var (_, name, limit, factor, _, _) in currentMonthlyExpenseItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "" : limit.ToString("N0"))}</td>
<td>{FormatMonthsByFactor(factor)}</td>
<td>{(limit == 0 ? "不限额" : (limit * factor).ToString("N0"))}</td>
</tr>
""");
}
// 年预算
foreach (var (_, name, limit, _, _, _) in currentYearlyExpenseItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "" : limit.ToString("N0"))}</td>
<td>{year}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
// 合计
description.AppendLine($"""
<p>
支出预算合计:
<span class='expense-value'>
<strong>
{currentMonthlyExpenseItems.Sum(i => i.limit * i.factor)
+ currentYearlyExpenseItems.Sum(i => i.limit):N0}
</strong>
</span>
</p>
""");
#endregion
#region
var archiveIncomeBudget = archiveIncomeItems.Sum(i => i.limit * i.months.Length);
var archiveExpenseBudget = archiveExpenseItems.Sum(i => i.limit * i.months.Length);
// 如果有归档存款数据,直接使用;否则用收入-支出计算
var archiveSavings = archiveSavingsItems.Any()
? archiveSavingsItems.Sum(i => i.current)
: archiveIncomeBudget - archiveExpenseBudget + archiveIncomeDiff + archiveExpenseDiff;
var expectedIncome = currentMonthlyIncomeItems.Sum(i => i.limit == 0 ? i.current : i.limit * i.factor) + currentYearlyIncomeItems.Sum(i => i.isMandatory || i.limit == 0 ? i.current : i.limit);
var expectedExpense = currentMonthlyExpenseItems.Sum(i => i.limit == 0 ? i.current : i.limit * i.factor) + currentYearlyExpenseItems.Sum(i => i.isMandatory || i.limit == 0 ? i.current : i.limit);
var expectedSavings = expectedIncome - expectedExpense;
description.AppendLine("<h3>存款计划结论</h3>");
description.AppendLine($"""
<p>
<strong>归档存款:</strong>
<span class='income-value'><strong>{archiveSavings:N0}</strong></span>
=
归档收入: <span class='income-value'>{archiveIncomeBudget:N0}</span>
-
归档支出: <span class='expense-value'>{archiveExpenseBudget:N0}</span>
{(archiveIncomeDiff >= 0 ? " + " : " - ")}: <span class='{(archiveIncomeDiff >= 0 ? "income-value" : "expense-value")}'>{(archiveIncomeDiff >= 0 ? archiveIncomeDiff : -archiveIncomeDiff):N0}</span>
{(archiveExpenseDiff >= 0 ? " + 节省支出" : " - 超额支出")}: <span class='{(archiveExpenseDiff >= 0 ? "income-value" : "expense-value")}'>{(archiveExpenseDiff >= 0 ? archiveExpenseDiff : -archiveExpenseDiff):N0}</span>
</p>
<p>
<strong></strong>
<span class='income-value'><strong>{expectedSavings:N0}</strong></span>
=
: <span class='income-value'>{expectedIncome:N0}</span>
-
: <span class='expense-value'>{expectedExpense:N0}</span>
</p>
<p>
<strong></strong>
<span class='{(archiveSavings + expectedSavings > 0 ? "income-value" : "expense-value")}'>
<strong>{archiveSavings + expectedSavings:N0}</strong>
</span>
=
:
<span class='income-value'>{expectedSavings:N0}</span>
{(archiveSavings > 0 ? "+" : "-")}
:
<span class='{(archiveSavings > 0 ? "income-value" : "expense-value")}'>{Math.Abs(archiveSavings):N0}</span>
</p>
""");
#endregion
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
var currentActual = 0m;
if (!string.IsNullOrEmpty(savingsCategories))
{
var cats = new HashSet<string>(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries));
foreach (var kvp in transactionClassify)
{
if (cats.Contains(kvp.Key.Item1))
{
currentActual += kvp.Value;
}
}
}
var record = new BudgetRecord
{
Id = -1,
Name = "年度存款计划",
Type = BudgetPeriodType.Year,
Limit = archiveSavings + expectedSavings,
Category = BudgetCategory.Savings,
SelectedCategories = savingsCategories,
StartDate = new DateTime(year, 1, 1),
NoLimit = false,
IsMandatoryExpense = false,
CreateTime = dateTimeProvider.Now,
UpdateTime = dateTimeProvider.Now
};
return BudgetResult.FromEntity(
record,
currentActual,
new DateTime(year, 1, 1),
description.ToString()
);
void AddOrIncCurrentItem(
long id,
BudgetPeriodType periodType,
BudgetCategory category,
string name,
decimal limit,
int factor,
decimal incAmount,
bool isMandatory)
{
var current = (periodType, category) switch
{
(BudgetPeriodType.Month, BudgetCategory.Income) => currentMonthlyIncomeItems,
(BudgetPeriodType.Month, BudgetCategory.Expense) => currentMonthlyExpenseItems,
(BudgetPeriodType.Year, BudgetCategory.Income) => currentYearlyIncomeItems,
(BudgetPeriodType.Year, BudgetCategory.Expense) => currentYearlyExpenseItems,
_ => throw new NotSupportedException($"Category {category} is not supported.")
};
if (current.Any(i => i.id == id))
{
var existing = current.First(i => i.id == id);
current.Remove(existing);
current.Add((id, existing.name, existing.limit, existing.factor + factor, existing.current + incAmount, isMandatory));
}
else
{
current.Add((id, name, limit, factor, incAmount, isMandatory));
}
}
string FormatMonthsByFactor(int factor)
{
var months = factor == 12
? Enumerable.Range(1, 12).ToArray()
: Enumerable.Range(dateTimeProvider.Now.Month, factor).ToArray();
return FormatMonths(months.ToArray());
}
string FormatMonths(int[] months)
{
// 如果是连续的月份 则简化显示 1~3
Array.Sort(months);
if (months.Length >= 2)
{
var isContinuous = true;
for (var i = 1; i < months.Length; i++)
{
if (months[i] != months[i - 1] + 1)
{
isContinuous = false;
break;
}
}
if (isContinuous)
{
return $"{months.First()}~{months.Last()}月";
}
}
return string.Join(", ", months) + "月";
}
}
}

View File

@@ -0,0 +1,548 @@
using Service.AI;
using Service.Message;
using Service.Transaction;
namespace Service.Budget;
public interface IBudgetService
{
Task<List<BudgetResult>> GetListAsync(DateTime referenceDate);
Task<string> ArchiveBudgetsAsync(int year, int month);
/// <summary>
/// 获取指定分类的统计信息(月度和年度)
/// </summary>
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
/// <summary>
/// 获取未被预算覆盖的分类统计信息
/// </summary>
Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
Task<string?> GetArchiveSummaryAsync(int year, int month);
/// <summary>
/// 获取指定周期的存款预算信息
/// </summary>
Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
}
[UsedImplicitly]
public class BudgetService(
IBudgetRepository budgetRepository,
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionRecordRepository transactionRecordRepository,
ITransactionStatisticsService transactionStatisticsService,
IOpenAiService openAiService,
IMessageService messageService,
ILogger<BudgetService> logger,
IBudgetSavingsService budgetSavingsService,
IDateTimeProvider dateTimeProvider,
IBudgetStatsService budgetStatsService
) : IBudgetService
{
public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
{
var year = referenceDate.Year;
var month = referenceDate.Month;
var isArchive = year < dateTimeProvider.Now.Year
|| (year == dateTimeProvider.Now.Year && month < dateTimeProvider.Now.Month);
if (isArchive)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
if (archive != null)
{
var (start, end) = GetPeriodRange(dateTimeProvider.Now, BudgetPeriodType.Month, referenceDate);
return [.. archive.Content.Select(c => new BudgetResult
{
Id = c.Id,
Name = c.Name,
Type = c.Type,
Limit = c.Limit,
Current = c.Actual,
Category = c.Category,
SelectedCategories = c.SelectedCategories,
NoLimit = c.NoLimit,
IsMandatoryExpense = c.IsMandatoryExpense,
Description = c.Description,
PeriodStart = start,
PeriodEnd = end,
})];
}
logger.LogWarning("获取预算列表时发现归档数据缺失Year: {Year}, Month: {Month}", year, month);
}
var budgets = await budgetRepository.GetAllAsync();
var dtos = new List<BudgetResult?>();
foreach (var budget in budgets)
{
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
dtos.Add(BudgetResult.FromEntity(budget, currentAmount, referenceDate));
}
// 创造虚拟的存款预算
dtos.Add(await budgetSavingsService.GetSavingsDtoAsync(
BudgetPeriodType.Month,
referenceDate,
budgets));
dtos.Add(await budgetSavingsService.GetSavingsDtoAsync(
BudgetPeriodType.Year,
referenceDate,
budgets));
dtos = dtos
.Where(x => x != null)
.Cast<BudgetResult>()
.OrderByDescending(x => x.IsMandatoryExpense)
.ThenBy(x => x.Type)
.ThenByDescending(x => x.Current)
.ToList()!;
return [.. dtos.Where(dto => dto != null).Cast<BudgetResult>()];
}
public async Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
{
var referenceDate = new DateTime(year, month, 1);
return await budgetSavingsService.GetSavingsDtoAsync(type, referenceDate);
}
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{
return await budgetStatsService.GetCategoryStatsAsync(category, referenceDate);
}
public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null)
{
var date = referenceDate ?? dateTimeProvider.Now;
var transactionType = category switch
{
BudgetCategory.Expense => TransactionType.Expense,
BudgetCategory.Income => TransactionType.Income,
_ => TransactionType.None
};
if (transactionType == TransactionType.None) return [];
// 1. 获取所有预算
var budgets = (await budgetRepository.GetAllAsync()).ToList();
var coveredCategories = budgets
.Where(b => b.Category == category)
.SelectMany(b => b.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries))
.ToHashSet();
// 2. 获取分类统计
var stats = await transactionStatisticsService.GetCategoryStatisticsAsync(date.Year, date.Month, transactionType);
// 3. 过滤未覆盖的
return stats
.Where(s => !coveredCategories.Contains(s.Classify))
.Select(s => new UncoveredCategoryDetail
{
Category = s.Classify,
TransactionCount = s.Count,
TotalAmount = s.Amount
})
.OrderByDescending(x => x.TotalAmount)
.ToList();
}
public async Task<string?> GetArchiveSummaryAsync(int year, int month)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
return archive?.Summary;
}
public async Task<string> ArchiveBudgetsAsync(int year, int month)
{
var referenceDate = new DateTime(year, month, 1);
var budgets = await GetListAsync(referenceDate);
var expenseSurplus = budgets
.Where(b => b.Category == BudgetCategory.Expense && !b.NoLimit && b.Type == BudgetPeriodType.Month)
.Sum(b => b.Limit - b.Current);
var incomeSurplus = budgets
.Where(b => b.Category == BudgetCategory.Income && !b.NoLimit && b.Type == BudgetPeriodType.Month)
.Sum(b => b.Current - b.Limit);
var content = budgets.Select(b => new BudgetArchiveContent
{
Id = b.Id,
Name = b.Name,
Type = b.Type,
Limit = b.Limit,
Actual = b.Current,
Category = b.Category,
SelectedCategories = b.SelectedCategories,
NoLimit = b.NoLimit,
IsMandatoryExpense = b.IsMandatoryExpense,
Description = b.Description
}).ToArray();
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
if (archive != null)
{
archive.Content = content;
archive.ArchiveDate = dateTimeProvider.Now;
archive.ExpenseSurplus = expenseSurplus;
archive.IncomeSurplus = incomeSurplus;
if (!await budgetArchiveRepository.UpdateAsync(archive))
{
return "更新预算归档失败";
}
}
else
{
archive = new BudgetArchive
{
Year = year,
Month = month,
Content = content,
ArchiveDate = dateTimeProvider.Now,
ExpenseSurplus = expenseSurplus,
IncomeSurplus = incomeSurplus
};
if (!await budgetArchiveRepository.AddAsync(archive))
{
return "保存预算归档失败";
}
}
_ = NotifyAsync(year, month);
return string.Empty;
}
private async Task NotifyAsync(int year, int month)
{
try
{
var archives = await budgetArchiveRepository.GetListAsync(year, month);
var archiveData = archives.SelectMany(a => a.Content.Select(c => new
{
c.Name,
Type = c.Type.ToString(),
c.Limit,
c.Actual,
Category = c.Category.ToString(),
c.SelectedCategories
})).ToList();
var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
$"""
SELECT
COUNT(*) AS TransactionCount,
SUM(ABS(Amount)) AS TotalAmount,
Type,
Classify
FROM TransactionRecord
WHERE OccurredAt >= '{year}-01-01'
AND OccurredAt < '{year + 1}-01-01'
GROUP BY Type, Classify
ORDER BY TotalAmount DESC
"""
);
var monthYear = new DateTime(year, month, 1).AddMonths(1);
var monthTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
$"""
SELECT
COUNT(*) AS TransactionCount,
SUM(ABS(Amount)) AS TotalAmount,
Type,
Classify
FROM TransactionRecord
WHERE OccurredAt >= '{year}-{month:00}-01'
AND OccurredAt < '{monthYear:yyyy-MM-dd}'
GROUP BY Type, Classify
ORDER BY TotalAmount DESC
"""
);
// 分析未被预算覆盖的分类 (仅针对支出类型 Type=0)
var budgetedCategories = archiveData
.SelectMany(b => b.SelectedCategories)
.Where(c => !string.IsNullOrEmpty(c))
.Distinct()
.ToHashSet();
var uncovered = monthTransactions
.Where(t =>
{
var dict = (IDictionary<string, object>)t;
var classify = dict["Classify"].ToString() ?? "";
var type = Convert.ToInt32(dict["Type"]);
return type == 0 && !budgetedCategories.Contains(classify);
})
.ToList();
logger.LogInformation("预算执行数据{JSON}", JsonSerializer.Serialize(archiveData));
logger.LogInformation("本月消费明细{JSON}", JsonSerializer.Serialize(monthTransactions));
logger.LogInformation("全年累计消费概况{JSON}", JsonSerializer.Serialize(yearTransactions));
logger.LogInformation("未被预算覆盖的分类{JSON}", JsonSerializer.Serialize(uncovered));
var dataPrompt = $"""
报告周期:{year}年{month}月
1. 预算执行数据JSON
{JsonSerializer.Serialize(archiveData)}
2. 本月账单类目明细(按分类, JSON
{JsonSerializer.Serialize(monthTransactions)}
3. 全年累计账单类目明细(按分类, JSON
{JsonSerializer.Serialize(yearTransactions)}
4. 未被任何预算覆盖的支出分类JSON
{JsonSerializer.Serialize(uncovered)}
请生成一份专业且美观的预算执行分析报告,严格遵守以下要求:
【内容要求】
1. 概览:总结本月预算达成情况。
2. 预算详情:使用 HTML 表格展示预算执行明细(预算项、预算额、实际额、使用/达成率、状态)。
3. 超支/异常预警:重点分析超支项或支出异常的分类。
4. 消费透视:针对“未被预算覆盖的支出”提供分析建议。分析这些账单产生的合理性,并评估是否需要为其中的大额或频发分类建立新预算。
5. 改进建议:根据当前时间进度和预算完成进度,基于本月整体收入支出情况,给出下月预算调整或消费改进的专业化建议。
6. 语言风格:专业、清晰、简洁,适合财务报告阅读。
7. 如果报告月份是12月需要报告年度预算的执行情况。
【格式要求】
1. 使用HTML格式移动端H5页面风格
2. 生成清晰的报告标题(基于用户问题)
3. 使用表格展示统计数据table > thead/tbody > tr > th/td
3.1 table要求不能超过屏幕宽度尽可能简洁明了避免冗余信息
3.2 预算金额精确到整数即可实际金额精确到小数点后1位
4. 使用合适的HTML标签h2标题、h3小节、p段落、table表格、ul/li列表、strong强调
5. 支出金额用 <span class='expense-value'>金额</span> 包裹
6. 收入金额用 <span class='income-value'>金额</span> 包裹
7. 重要结论用 <span class='highlight'>内容</span> 高亮
【样式限制(重要)】
8. 不要包含 html、body、head 标签
9. 不要使用任何 style 属性或 <style> 标签
10. 不要设置 background、background-color、color 等样式属性
11. 不要使用 div 包裹大段内容
【系统信息】
当前时间:{dateTimeProvider.Now:yyyy-MM-dd HH:mm:ss}
预算归档周期:{year}年{month}月
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
""";
var htmlReport = await openAiService.ChatAsync(dataPrompt);
if (!string.IsNullOrEmpty(htmlReport))
{
await messageService.AddAsync(
title: $"{year}年{month}月 - 预算归档报告",
content: htmlReport,
type: MessageType.Html,
url: "/balance?tab=message");
// 同时保存到归档总结
var first = archives.First();
first.Summary = htmlReport;
await budgetArchiveRepository.UpdateAsync(first);
}
}
catch (Exception ex)
{
logger.LogError(ex, "生成预算执行通知报告失败");
}
}
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
{
var referenceDate = now ?? dateTimeProvider.Now;
var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
// 如果是硬性消费,且是当前年当前月,则根据经过的天数累加
if (actualAmount == 0
&& budget.IsMandatoryExpense
&& referenceDate.Year == startDate.Year
&& (budget.Type == BudgetPeriodType.Year || referenceDate.Month == startDate.Month))
{
if (budget.Type == BudgetPeriodType.Month)
{
// 计算本月的天数
var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month);
// 计算当前已经过的天数(包括今天)
var daysElapsed = referenceDate.Day;
// 根据预算金额和经过天数计算应累加的金额
var mandatoryAccumulation = budget.Limit * daysElapsed / daysInMonth;
// 返回实际消费和硬性消费累加中的较大值
return mandatoryAccumulation;
}
if (budget.Type == BudgetPeriodType.Year)
{
// 计算本年的天数(考虑闰年)
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
// 计算当前已经过的天数(包括今天)
var daysElapsed = referenceDate.DayOfYear;
// 根据预算金额和经过天数计算应累加的金额
var mandatoryAccumulation = budget.Limit * daysElapsed / daysInYear;
// 返回实际消费和硬性消费累加中的较大值
return mandatoryAccumulation;
}
}
return actualAmount;
}
internal static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate)
{
DateTime start;
DateTime end;
if (type == BudgetPeriodType.Month)
{
start = new DateTime(referenceDate.Year, referenceDate.Month, 1);
end = start.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
}
else if (type == BudgetPeriodType.Year)
{
start = new DateTime(referenceDate.Year, 1, 1);
end = new DateTime(referenceDate.Year, 12, 31).AddHours(23).AddMinutes(59).AddSeconds(59);
}
else
{
start = startDate;
end = DateTime.MaxValue;
}
return (start, end);
}
}
public record BudgetResult
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public BudgetPeriodType Type { get; set; }
public decimal Limit { get; set; }
public decimal Current { get; set; }
public BudgetCategory Category { get; set; }
public string[] SelectedCategories { get; set; } = [];
public string StartDate { get; set; } = string.Empty;
public string Period { get; set; } = string.Empty;
public DateTime? PeriodStart { get; set; }
public DateTime? PeriodEnd { get; set; }
public bool NoLimit { get; set; }
public bool IsMandatoryExpense { get; set; }
public string Description { get; set; } = string.Empty;
public static BudgetResult FromEntity(
BudgetRecord entity,
decimal currentAmount,
DateTime referenceDate,
string description = "")
{
var date = referenceDate;
var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date);
return new BudgetResult
{
Id = entity.Id,
Name = entity.Name,
Type = entity.Type,
Limit = entity.Limit,
Current = currentAmount,
Category = entity.Category,
SelectedCategories = string.IsNullOrEmpty(entity.SelectedCategories)
? []
: entity.SelectedCategories.Split(','),
StartDate = entity.StartDate.ToString("yyyy-MM-dd"),
Period = entity.Type switch
{
BudgetPeriodType.Year => $"{start:yy}年",
BudgetPeriodType.Month => $"{start:yy}年第{start.Month}月",
_ => $"{start:yyyy-MM-dd} ~ {end:yyyy-MM-dd}"
},
PeriodStart = start,
PeriodEnd = end,
NoLimit = entity.NoLimit,
IsMandatoryExpense = entity.IsMandatoryExpense,
Description = description
};
}
}
/// <summary>
/// 预算统计结果 DTO
/// </summary>
public class BudgetStatsDto
{
/// <summary>
/// 统计周期类型Month/Year
/// </summary>
public BudgetPeriodType PeriodType { get; set; }
/// <summary>
/// 使用率百分比0-100
/// </summary>
public decimal Rate { get; set; }
/// <summary>
/// 实际金额
/// </summary>
public decimal Current { get; set; }
/// <summary>
/// 目标/限额金额
/// </summary>
public decimal Limit { get; set; }
/// <summary>
/// 预算项数量
/// </summary>
public int Count { get; set; }
/// <summary>
/// 每日/每月累计金额趋势(对应当前周期内的实际发生额累计值)
/// </summary>
public List<decimal?> Trend { get; set; } = [];
/// <summary>
/// HTML 格式的详细描述(罗列每个预算的额度和实际值及计算公式)
/// </summary>
public string Description { get; set; } = string.Empty;
}
/// <summary>
/// 分类统计结果
/// </summary>
public class BudgetCategoryStats
{
/// <summary>
/// 月度统计
/// </summary>
public BudgetStatsDto Month { get; set; } = new();
/// <summary>
/// 年度统计
/// </summary>
public BudgetStatsDto Year { get; set; } = new();
}
public class UncoveredCategoryDetail
{
public string Category { get; set; } = string.Empty;
public int TransactionCount { get; set; }
public decimal TotalAmount { get; set; }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -43,12 +43,12 @@ public class ConfigService(IConfigRepository configRepository) : IConfigService
var config = await configRepository.GetByKeyAsync(key); var config = await configRepository.GetByKeyAsync(key);
var type = typeof(T) switch var type = typeof(T) switch
{ {
Type t when t == typeof(bool) => ConfigType.Boolean, { } t when t == typeof(bool) => ConfigType.Boolean,
Type t when t == typeof(int) { } t when t == typeof(int)
|| t == typeof(double) || t == typeof(double)
|| t == typeof(float) || t == typeof(float)
|| t == typeof(decimal) => ConfigType.Number, || t == typeof(decimal) => ConfigType.Number,
Type t when t == typeof(string) => ConfigType.String, { } t when t == typeof(string) => ConfigType.String,
_ => ConfigType.Json _ => ConfigType.Json
}; };
var valueStr = type switch var valueStr = type switch

View File

@@ -74,13 +74,13 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
_useSsl = useSsl; _useSsl = useSsl;
_email = email; _email = email;
_password = password; _password = password;
// 如果已连接,先断开 // 如果已连接,先断开
if (_imapClient?.IsConnected == true) if (_imapClient?.IsConnected == true)
{ {
await DisconnectAsync(); await DisconnectAsync();
} }
_imapClient = new ImapClient(); _imapClient = new ImapClient();
if (useSsl) if (useSsl)
@@ -206,7 +206,7 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
// 标记邮件为已读设置Seen标记 // 标记邮件为已读设置Seen标记
await inbox.AddFlagsAsync(uid, MessageFlags.Seen, silent: false); await inbox.AddFlagsAsync(uid, MessageFlags.Seen, silent: false);
_logger.LogDebug("邮件 {Uid} 标记已读操作已提交", uid); _logger.LogDebug("邮件 {Uid} 标记已读操作已提交", uid);
} }
catch (Exception ex) catch (Exception ex)
@@ -240,13 +240,13 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
} }
return _imapClient?.IsConnected == true; return _imapClient?.IsConnected == true;
} }
if (string.IsNullOrEmpty(_host) || string.IsNullOrEmpty(_email)) if (string.IsNullOrEmpty(_host) || string.IsNullOrEmpty(_email))
{ {
_logger.LogWarning("未初始化连接信息,无法自动重连"); _logger.LogWarning("未初始化连接信息,无法自动重连");
return false; return false;
} }
_logger.LogInformation("检测到连接断开,尝试重新连接到 {Email}...", _email); _logger.LogInformation("检测到连接断开,尝试重新连接到 {Email}...", _email);
return await ConnectAsync(_host, _port, _useSsl, _email, _password); return await ConnectAsync(_host, _port, _useSsl, _email, _password);
} }

View File

@@ -1,4 +1,6 @@
using Service.EmailParseServices; using Service.AI;
using Service.EmailServices.EmailParse;
using Service.Message;
namespace Service.EmailServices; namespace Service.EmailServices;
@@ -65,7 +67,7 @@ public class EmailHandleService(
await messageService.AddAsync( await messageService.AddAsync(
"邮件解析失败", "邮件解析失败",
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。", $"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。",
url: $"/balance?tab=email" url: "/balance?tab=email"
); );
logger.LogWarning("未能成功解析邮件内容,跳过账单处理"); logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
return true; return true;
@@ -73,7 +75,7 @@ public class EmailHandleService(
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length); logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
bool allSuccess = true; var allSuccess = true;
var records = new List<TransactionRecord>(); var records = new List<TransactionRecord>();
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed) foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
{ {
@@ -142,7 +144,7 @@ public class EmailHandleService(
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length); logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
bool allSuccess = true; var allSuccess = true;
var records = new List<TransactionRecord>(); var records = new List<TransactionRecord>();
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed) foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
{ {
@@ -177,7 +179,7 @@ public class EmailHandleService(
{ {
var clone = records.ToArray().DeepClone(); var clone = records.ToArray().DeepClone();
if(clone?.Any() != true) if (clone?.Any() != true)
{ {
return; return;
} }

View File

@@ -1,4 +1,6 @@
namespace Service.EmailParseServices; using Service.AI;
namespace Service.EmailServices.EmailParse;
public class EmailParseForm95555( public class EmailParseForm95555(
ILogger<EmailParseForm95555> logger, ILogger<EmailParseForm95555> logger,
@@ -26,7 +28,7 @@ public class EmailParseForm95555(
return true; return true;
} }
public override async Task<( public override Task<(
string card, string card,
string reason, string reason,
decimal amount, decimal amount,
@@ -51,7 +53,7 @@ public class EmailParseForm95555(
if (matches.Count <= 0) if (matches.Count <= 0)
{ {
logger.LogWarning("未能从招商银行邮件内容中解析出交易信息"); logger.LogWarning("未能从招商银行邮件内容中解析出交易信息");
return []; return Task.FromResult<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]>([]);
} }
var results = new List<( var results = new List<(
@@ -70,7 +72,7 @@ public class EmailParseForm95555(
var balanceStr = match.Groups["balance"].Value; var balanceStr = match.Groups["balance"].Value;
var typeStr = match.Groups["type"].Value; var typeStr = match.Groups["type"].Value;
var reason = match.Groups["reason"].Value; var reason = match.Groups["reason"].Value;
if(string.IsNullOrEmpty(reason)) if (string.IsNullOrEmpty(reason))
{ {
reason = typeStr; reason = typeStr;
} }
@@ -85,7 +87,7 @@ public class EmailParseForm95555(
results.Add((card, reason, amount, balance, type, occurredAt)); results.Add((card, reason, amount, balance, type, occurredAt));
} }
} }
return results.ToArray(); return Task.FromResult(results.ToArray());
} }
private DateTime? ParseOccurredAt(string value) private DateTime? ParseOccurredAt(string value)

View File

@@ -1,12 +1,19 @@
using HtmlAgilityPack; using HtmlAgilityPack;
using Service.AI;
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
namespace Service.EmailParseServices; namespace Service.EmailServices.EmailParse;
public class EmailParseFormCCSVC( [UsedImplicitly]
ILogger<EmailParseFormCCSVC> logger, public partial class EmailParseFormCcsvc(
ILogger<EmailParseFormCcsvc> logger,
IOpenAiService openAiService IOpenAiService openAiService
) : EmailParseServicesBase(logger, openAiService) ) : EmailParseServicesBase(logger, openAiService)
{ {
[GeneratedRegex("<.*?>")]
private static partial Regex HtmlRegex();
public override bool CanParse(string from, string subject, string body) public override bool CanParse(string from, string subject, string body)
{ {
if (!from.Contains("ccsvc@message.cmbchina.com")) if (!from.Contains("ccsvc@message.cmbchina.com"))
@@ -20,12 +27,7 @@ public class EmailParseFormCCSVC(
} }
// 必须包含HTML标签 // 必须包含HTML标签
if (!Regex.IsMatch(body, "<.*?>")) return HtmlRegex().IsMatch(body);
{
return false;
}
return true;
} }
public override async Task<( public override async Task<(
@@ -47,7 +49,7 @@ public class EmailParseFormCCSVC(
if (dateNode == null) if (dateNode == null)
{ {
logger.LogWarning("Date node not found"); logger.LogWarning("Date node not found");
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>(); return [];
} }
var dateText = dateNode.InnerText.Trim(); var dateText = dateNode.InnerText.Trim();
@@ -56,7 +58,7 @@ public class EmailParseFormCCSVC(
if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date)) if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date))
{ {
logger.LogWarning("Failed to parse date from: {DateText}", dateText); logger.LogWarning("Failed to parse date from: {DateText}", dateText);
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>(); return [];
} }
// 2. Get Balance (Available Limit) // 2. Get Balance (Available Limit)
@@ -90,6 +92,7 @@ public class EmailParseFormCCSVC(
{ {
foreach (var node in transactionNodes) foreach (var node in transactionNodes)
{ {
var card = "";
try try
{ {
// Time // Time
@@ -122,30 +125,23 @@ public class EmailParseFormCCSVC(
descText = HtmlEntity.DeEntitize(descText).Replace((char)160, ' ').Trim(); descText = HtmlEntity.DeEntitize(descText).Replace((char)160, ' ').Trim();
// Parse Description: "尾号4390 消费 财付通-luckincoffee瑞幸咖啡" // Parse Description: "尾号4390 消费 财付通-luckincoffee瑞幸咖啡"
var parts = descText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); var parts = descText.Split([' '], StringSplitOptions.RemoveEmptyEntries);
string card = ""; var reason = descText;
string reason = descText; TransactionType type;
TransactionType type = TransactionType.Expense;
if (parts.Length > 0 && parts[0].StartsWith("尾号")) if (parts.Length > 0 && parts[0].StartsWith("尾号"))
{ {
card = parts[0].Replace("尾号", ""); card = parts[0].Replace("尾号", "");
} }
if (parts.Length > 1)
{
var typeStr = parts[1];
type = DetermineTransactionType(typeStr, reason, amount);
}
if (parts.Length > 2) if (parts.Length > 2)
{ {
reason = string.Join(" ", parts.Skip(2)); reason = string.Join(" ", parts.Skip(2));
} }
// 招商信用卡特殊,消费金额为正数,退款为负数 // 招商信用卡特殊,消费金额为正数,退款为负数
if(amount > 0) if (amount > 0)
{ {
type = TransactionType.Expense; type = TransactionType.Expense;
} }

View File

@@ -1,4 +1,6 @@
namespace Service.EmailParseServices; using Service.AI;
namespace Service.EmailServices.EmailParse;
public interface IEmailParseServices public interface IEmailParseServices
{ {
@@ -45,7 +47,7 @@ public abstract class EmailParseServicesBase(
// AI兜底 // AI兜底
result = await ParseByAiAsync(emailContent) ?? []; result = await ParseByAiAsync(emailContent) ?? [];
if(result.Length == 0) if (result.Length == 0)
{ {
logger.LogWarning("AI解析邮件内容也未能提取到任何交易记录"); logger.LogWarning("AI解析邮件内容也未能提取到任何交易记录");
} }
@@ -63,10 +65,10 @@ public abstract class EmailParseServicesBase(
)[]> ParseEmailContentAsync(string emailContent); )[]> ParseEmailContentAsync(string emailContent);
private async Task<( private async Task<(
string card, string card,
string reason, string reason,
decimal amount, decimal amount,
decimal balance, decimal balance,
TransactionType type, TransactionType type,
DateTime? occurredAt DateTime? occurredAt
)[]?> ParseByAiAsync(string body) )[]?> ParseByAiAsync(string body)
@@ -148,19 +150,19 @@ public abstract class EmailParseServicesBase(
private (string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)? ParseSingleRecord(JsonElement obj) private (string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)? ParseSingleRecord(JsonElement obj)
{ {
string card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty; var card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty;
string reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty; var reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty;
string typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty; var typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty;
string occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty; var occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty;
decimal amount = 0m; var amount = 0m;
if (obj.TryGetProperty("amount", out var pAmount)) if (obj.TryGetProperty("amount", out var pAmount))
{ {
if (pAmount.ValueKind == JsonValueKind.Number && pAmount.TryGetDecimal(out var d)) amount = d; 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; else if (pAmount.ValueKind == JsonValueKind.String && decimal.TryParse(pAmount.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) amount = ds;
} }
decimal balance = 0m; var balance = 0m;
if (obj.TryGetProperty("balance", out var pBalance)) if (obj.TryGetProperty("balance", out var pBalance))
{ {
if (pBalance.ValueKind == JsonValueKind.Number && pBalance.TryGetDecimal(out var d2)) balance = d2; if (pBalance.ValueKind == JsonValueKind.Number && pBalance.TryGetDecimal(out var d2)) balance = d2;
@@ -173,7 +175,7 @@ public abstract class EmailParseServicesBase(
} }
var occurredAt = (DateTime?)null; var occurredAt = (DateTime?)null;
if(DateTime.TryParse(occurredAtStr, out var occurredAtValue)) if (DateTime.TryParse(occurredAtStr, out var occurredAtValue))
{ {
occurredAt = occurredAtValue; occurredAt = occurredAtValue;
} }
@@ -201,7 +203,7 @@ public abstract class EmailParseServicesBase(
// 收入关键词 // 收入关键词
string[] incomeKeywords = string[] incomeKeywords =
{ [
"工资", "奖金", "退款", "工资", "奖金", "退款",
"返现", "收入", "转入", "返现", "收入", "转入",
"存入", "利息", "分红", "存入", "利息", "分红",
@@ -233,13 +235,13 @@ public abstract class EmailParseServicesBase(
// 存取类 // 存取类
"现金存入", "柜台存入", "ATM存入", "现金存入", "柜台存入", "ATM存入",
"他人转入", "他人汇入" "他人转入", "他人汇入"
}; ];
if (incomeKeywords.Any(k => lowerReason.Contains(k))) if (incomeKeywords.Any(k => lowerReason.Contains(k)))
return TransactionType.Income; return TransactionType.Income;
// 支出关键词 // 支出关键词
string[] expenseKeywords = string[] expenseKeywords =
{ [
"消费", "支付", "购买", "消费", "支付", "购买",
"转出", "取款", "支出", "转出", "取款", "支出",
"扣款", "缴费", "付款", "扣款", "缴费", "付款",
@@ -269,7 +271,7 @@ public abstract class EmailParseServicesBase(
// 信用卡/花呗等场景 // 信用卡/花呗等场景
"信用卡还款", "花呗还款", "白条还款", "信用卡还款", "花呗还款", "白条还款",
"分期还款", "账单还款", "自动还款" "分期还款", "账单还款", "自动还款"
}; ];
if (expenseKeywords.Any(k => lowerReason.Contains(k))) if (expenseKeywords.Any(k => lowerReason.Contains(k)))
return TransactionType.Expense; return TransactionType.Expense;

View File

@@ -182,6 +182,7 @@ public class EmailSyncService(
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync(); var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count); logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
// ReSharper disable once UnusedVariable
foreach (var (message, uid) in unreadMessages) foreach (var (message, uid) in unreadMessages)
{ {
try try
@@ -198,12 +199,12 @@ public class EmailSyncService(
message.TextBody ?? message.HtmlBody ?? string.Empty message.TextBody ?? message.HtmlBody ?? string.Empty
) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3))) ) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3)))
{ {
#if DEBUG #if DEBUG
logger.LogDebug("DEBUG 模式下,跳过标记已读步骤"); logger.LogDebug("DEBUG 模式下,跳过标记已读步骤");
#else #else
// 标记邮件为已读 // 标记邮件为已读
await emailFetchService.MarkAsReadAsync(uid); await emailFetchService.MarkAsReadAsync(uid);
#endif #endif
} }
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -7,10 +7,12 @@ global using System.Globalization;
global using System.Text; global using System.Text;
global using System.Text.Json; global using System.Text.Json;
global using Entity; global using Entity;
global using FreeSql;
global using System.Linq; global using System.Linq;
global using Service.AppSettingModel; global using Service.AppSettingModel;
global using System.Text.Json.Serialization; global using System.Text.Json.Serialization;
global using System.Text.Json.Nodes; global using System.Text.Json.Nodes;
global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.Configuration;
global using Common; global using Common;
global using System.Net;
global using System.Text.Encodings.Web;
global using JetBrains.Annotations;

View File

@@ -133,7 +133,7 @@ public class ImportService(
return DateTime.MinValue; return DateTime.MinValue;
} }
foreach (var format in DateTimeFormats) foreach (var format in _dateTimeFormats)
{ {
if (DateTime.TryParseExact( if (DateTime.TryParseExact(
row[key], row[key],
@@ -283,12 +283,12 @@ public class ImportService(
DateTime GetDateTimeValue(IDictionary<string, string> row, string key) DateTime GetDateTimeValue(IDictionary<string, string> row, string key)
{ {
if(!row.ContainsKey(key)) if (!row.ContainsKey(key))
{ {
return DateTime.MinValue; return DateTime.MinValue;
} }
foreach (var format in DateTimeFormats) foreach (var format in _dateTimeFormats)
{ {
if (DateTime.TryParseExact( if (DateTime.TryParseExact(
row[key], row[key],
@@ -358,14 +358,13 @@ public class ImportService(
{ {
return await ParseCsvAsync(file); return await ParseCsvAsync(file);
} }
else if (fileExtension == ".xlsx" || fileExtension == ".xls")
if (fileExtension == ".xlsx" || fileExtension == ".xls")
{ {
return await ParseExcelAsync(file); return await ParseExcelAsync(file);
} }
else
{ throw new NotSupportedException("不支持的文件格式");
throw new NotSupportedException("不支持的文件格式");
}
} }
private async Task<IDictionary<string, string>[]> ParseCsvAsync(MemoryStream file) private async Task<IDictionary<string, string>[]> ParseCsvAsync(MemoryStream file)
@@ -388,7 +387,7 @@ public class ImportService(
if (headers == null || headers.Length == 0) if (headers == null || headers.Length == 0)
{ {
return Array.Empty<IDictionary<string, string>>(); return [];
} }
var result = new List<IDictionary<string, string>>(); var result = new List<IDictionary<string, string>>();
@@ -420,7 +419,7 @@ public class ImportService(
if (worksheet == null || worksheet.Dimension == null) if (worksheet == null || worksheet.Dimension == null)
{ {
return Array.Empty<IDictionary<string, string>>(); return [];
} }
var rowCount = worksheet.Dimension.End.Row; var rowCount = worksheet.Dimension.End.Row;
@@ -428,12 +427,12 @@ public class ImportService(
if (rowCount < 2) if (rowCount < 2)
{ {
return Array.Empty<IDictionary<string, string>>(); return [];
} }
// 读取表头(第一行) // 读取表头(第一行)
var headers = new List<string>(); var headers = new List<string>();
for (int col = 1; col <= colCount; col++) for (var col = 1; col <= colCount; col++)
{ {
var header = worksheet.Cells[1, col].Text?.Trim() ?? string.Empty; var header = worksheet.Cells[1, col].Text?.Trim() ?? string.Empty;
headers.Add(header); headers.Add(header);
@@ -442,10 +441,10 @@ public class ImportService(
var result = new List<IDictionary<string, string>>(); var result = new List<IDictionary<string, string>>();
// 读取数据行(从第二行开始) // 读取数据行(从第二行开始)
for (int row = 2; row <= rowCount; row++) for (var row = 2; row <= rowCount; row++)
{ {
var rowData = new Dictionary<string, string>(); var rowData = new Dictionary<string, string>();
for (int col = 1; col <= colCount; col++) for (var col = 1; col <= colCount; col++)
{ {
var header = headers[col - 1]; var header = headers[col - 1];
var value = worksheet.Cells[row, col].Text?.Trim() ?? string.Empty; var value = worksheet.Cells[row, col].Text?.Trim() ?? string.Empty;
@@ -458,7 +457,7 @@ public class ImportService(
return await Task.FromResult(result.ToArray()); return await Task.FromResult(result.ToArray());
} }
private static string[] DateTimeFormats = private static string[] _dateTimeFormats =
[ [
"yyyy-MM-dd", "yyyy-MM-dd",
"yyyy-MM-dd HH", "yyyy-MM-dd HH",

View File

@@ -1,4 +1,5 @@
using Quartz; using Quartz;
using Service.Budget;
namespace Service.Jobs; namespace Service.Jobs;
@@ -23,7 +24,7 @@ public class BudgetArchiveJob(
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();
var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>(); var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>();
// 归档月度数据 // 归档月度数据
var result = await budgetService.ArchiveBudgetsAsync(year, month); var result = await budgetService.ArchiveBudgetsAsync(year, month);

View File

@@ -0,0 +1,150 @@
using Quartz;
using Service.AI;
namespace Service.Jobs;
/// <summary>
/// 分类图标生成定时任务
/// 每10分钟扫描一次为没有图标的分类生成 5 个 SVG 图标
/// </summary>
public class CategoryIconGenerationJob(
ITransactionCategoryRepository categoryRepository,
IOpenAiService openAiService,
ILogger<CategoryIconGenerationJob> logger) : IJob
{
private static readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task Execute(IJobExecutionContext context)
{
// 尝试获取锁,如果失败则跳过本次执行
if (!await _semaphore.WaitAsync(0))
{
logger.LogInformation("上一个分类图标生成任务尚未完成,跳过本次执行");
return;
}
try
{
logger.LogInformation("开始执行分类图标生成任务");
// 查询所有分类,然后过滤出没有图标的
var allCategories = await categoryRepository.GetAllAsync();
var categoriesWithoutIcon = allCategories
.Where(c => string.IsNullOrEmpty(c.Icon))
.ToList();
if (categoriesWithoutIcon.Count == 0)
{
logger.LogInformation("所有分类都已有图标,跳过本次任务");
return;
}
logger.LogInformation("发现 {Count} 个分类没有图标,开始生成", categoriesWithoutIcon.Count);
// 为每个分类生成图标
foreach (var category in categoriesWithoutIcon)
{
try
{
await GenerateIconsForCategoryAsync(category);
}
catch (Exception ex)
{
logger.LogError(ex, "为分类 {CategoryName}(ID:{CategoryId}) 生成图标失败",
category.Name, category.Id);
}
}
logger.LogInformation("分类图标生成任务执行完成");
}
catch (Exception ex)
{
logger.LogError(ex, "分类图标生成任务执行出错");
throw;
}
finally
{
_semaphore.Release();
}
}
/// <summary>
/// 为单个分类生成 5 个 SVG 图标
/// </summary>
private async Task GenerateIconsForCategoryAsync(TransactionCategory category)
{
logger.LogInformation("正在为分类 {CategoryName}(ID:{CategoryId}) 生成图标",
category.Name, category.Id);
var typeText = category.Type == 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 = $"""
分类名称:{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))
{
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", category.Name);
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 = 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);
}
}
}

View File

@@ -0,0 +1,70 @@
using Microsoft.Extensions.Hosting;
using Quartz;
namespace Service.Jobs;
/// <summary>
/// 数据库备份任务
/// </summary>
public class DbBackupJob(
IHostEnvironment env,
ILogger<DbBackupJob> logger) : IJob
{
public Task Execute(IJobExecutionContext context)
{
try
{
logger.LogInformation("开始执行数据库备份任务");
// 数据库文件路径 (基于 appsettings.json 中的配置: database/EmailBill.db)
var dbPath = Path.Combine(env.ContentRootPath, "database", "EmailBill.db");
var backupDir = Path.Combine(env.ContentRootPath, "database", "backups");
if (!File.Exists(dbPath))
{
logger.LogWarning("数据库文件不存在,跳过备份: {Path}", dbPath);
return Task.CompletedTask;
}
if (!Directory.Exists(backupDir))
{
Directory.CreateDirectory(backupDir);
}
// 创建备份
var backupFileName = $"EmailBill_backup_{DateTime.Now:yyyyMMdd}.db";
var backupPath = Path.Combine(backupDir, backupFileName);
File.Copy(dbPath, backupPath, true);
logger.LogInformation("数据库备份成功: {Path}", backupPath);
// 清理旧备份 (保留最近20个)
var files = new DirectoryInfo(backupDir).GetFiles("EmailBill_backup_*.db")
.OrderByDescending(f => f.LastWriteTime) // 使用 LastWriteTime 排序
.ToList();
if (files.Count > 20)
{
var filesToDelete = files.Skip(20);
foreach (var file in filesToDelete)
{
try
{
file.Delete();
logger.LogInformation("删除过期备份: {Name}", file.Name);
}
catch (Exception ex)
{
logger.LogError(ex, "删除过期备份失败: {Name}", file.Name);
}
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "数据库备份任务执行失败");
}
return Task.CompletedTask;
}
}

View File

@@ -127,6 +127,7 @@ public class EmailSyncJob(
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync(); var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count); logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
// ReSharper disable once UnusedVariable
foreach (var (message, uid) in unreadMessages) foreach (var (message, uid) in unreadMessages)
{ {
try try
@@ -143,12 +144,12 @@ public class EmailSyncJob(
message.TextBody ?? message.HtmlBody ?? string.Empty message.TextBody ?? message.HtmlBody ?? string.Empty
) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3))) ) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3)))
{ {
#if DEBUG #if DEBUG
logger.LogDebug("DEBUG 模式下,跳过标记已读步骤"); logger.LogDebug("DEBUG 模式下,跳过标记已读步骤");
#else #else
// 标记邮件为已读 // 标记邮件为已读
await emailFetchService.MarkAsReadAsync(uid); await emailFetchService.MarkAsReadAsync(uid);
#endif #endif
} }
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -0,0 +1,78 @@
using Quartz;
namespace Service.Jobs;
/// <summary>
/// 日志清理定时任务
/// </summary>
[DisallowConcurrentExecution]
public class LogCleanupJob(ILogger<LogCleanupJob> logger) : IJob
{
private const int RetentionDays = 30; // 保留30天的日志
public Task Execute(IJobExecutionContext context)
{
try
{
logger.LogInformation("开始执行日志清理任务");
var logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs");
if (!Directory.Exists(logDirectory))
{
logger.LogWarning("日志目录不存在: {LogDirectory}", logDirectory);
return Task.CompletedTask;
}
var cutoffDate = DateTime.Now.AddDays(-RetentionDays);
var logFiles = Directory.GetFiles(logDirectory, "log-*.txt");
var deletedCount = 0;
foreach (var logFile in logFiles)
{
try
{
var fileName = Path.GetFileNameWithoutExtension(logFile);
var dateStr = fileName.Replace("log-", "");
// 尝试解析日期 (格式: yyyyMMdd)
if (DateTime.TryParseExact(dateStr, "yyyyMMdd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var logDate))
{
if (logDate < cutoffDate)
{
File.Delete(logFile);
deletedCount++;
logger.LogInformation("已删除过期日志文件: {LogFile} (日期: {LogDate})",
Path.GetFileName(logFile), logDate.ToString("yyyy-MM-dd"));
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "删除日志文件失败: {LogFile}", logFile);
}
}
if (deletedCount > 0)
{
logger.LogInformation("日志清理完成,共删除 {DeletedCount} 个过期日志文件(保留 {RetentionDays} 天)",
deletedCount, RetentionDays);
}
else
{
logger.LogDebug("没有需要清理的过期日志文件");
}
logger.LogInformation("日志清理任务执行完成");
}
catch (Exception ex)
{
logger.LogError(ex, "日志清理任务执行出错");
throw; // 让 Quartz 知道任务失败
}
return Task.CompletedTask;
}
}

View File

@@ -1,4 +1,5 @@
using Quartz; using Quartz;
using Service.Transaction;
namespace Service.Jobs; namespace Service.Jobs;

View File

@@ -1,106 +0,0 @@
using Microsoft.Extensions.Hosting;
namespace Service;
/// <summary>
/// 日志清理后台服务
/// </summary>
public class LogCleanupService(ILogger<LogCleanupService> logger) : BackgroundService
{
private readonly TimeSpan _checkInterval = TimeSpan.FromHours(24); // 每24小时检查一次
private const int RetentionDays = 30; // 保留30天的日志
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("日志清理服务已启动");
// 启动时立即执行一次清理
await CleanupOldLogsAsync();
// 定期清理
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_checkInterval, stoppingToken);
await CleanupOldLogsAsync();
}
catch (OperationCanceledException)
{
// 服务正在停止
break;
}
catch (Exception ex)
{
logger.LogError(ex, "清理日志时发生错误");
}
}
logger.LogInformation("日志清理服务已停止");
}
/// <summary>
/// 清理过期的日志文件
/// </summary>
private async Task CleanupOldLogsAsync()
{
await Task.Run(() =>
{
try
{
var logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs");
if (!Directory.Exists(logDirectory))
{
logger.LogWarning("日志目录不存在: {LogDirectory}", logDirectory);
return;
}
var cutoffDate = DateTime.Now.AddDays(-RetentionDays);
var logFiles = Directory.GetFiles(logDirectory, "log-*.txt");
var deletedCount = 0;
foreach (var logFile in logFiles)
{
try
{
var fileName = Path.GetFileNameWithoutExtension(logFile);
var dateStr = fileName.Replace("log-", "");
// 尝试解析日期 (格式: yyyyMMdd)
if (DateTime.TryParseExact(dateStr, "yyyyMMdd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None,
out var logDate))
{
if (logDate < cutoffDate)
{
File.Delete(logFile);
deletedCount++;
logger.LogInformation("已删除过期日志文件: {LogFile} (日期: {LogDate})",
Path.GetFileName(logFile), logDate.ToString("yyyy-MM-dd"));
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "删除日志文件失败: {LogFile}", logFile);
}
}
if (deletedCount > 0)
{
logger.LogInformation("日志清理完成,共删除 {DeletedCount} 个过期日志文件(保留 {RetentionDays} 天)",
deletedCount, RetentionDays);
}
else
{
logger.LogDebug("没有需要清理的过期日志文件");
}
}
catch (Exception ex)
{
logger.LogError(ex, "清理日志过程中发生错误");
}
});
}
}

View File

@@ -1,4 +1,4 @@
namespace Service; namespace Service.Message;
public interface IMessageService public interface IMessageService
{ {
@@ -30,9 +30,9 @@ public class MessageService(IMessageRecordRepository messageRepo, INotificationS
} }
public async Task<bool> AddAsync( public async Task<bool> AddAsync(
string title, string title,
string content, string content,
MessageType type = MessageType.Text, MessageType type = MessageType.Text,
string? url = null string? url = null
) )
{ {
@@ -56,7 +56,7 @@ public class MessageService(IMessageRecordRepository messageRepo, INotificationS
{ {
var message = await messageRepo.GetByIdAsync(id); var message = await messageRepo.GetByIdAsync(id);
if (message == null) return false; if (message == null) return false;
message.IsRead = true; message.IsRead = true;
message.UpdateTime = DateTime.Now; message.UpdateTime = DateTime.Now;
return await messageRepo.UpdateAsync(message); return await messageRepo.UpdateAsync(message);

View File

@@ -1,11 +1,12 @@
using WebPush; using WebPush;
using PushSubscription = Entity.PushSubscription;
namespace Service; namespace Service.Message;
public interface INotificationService public interface INotificationService
{ {
Task<string> GetVapidPublicKeyAsync(); Task<string> GetVapidPublicKeyAsync();
Task SubscribeAsync(Entity.PushSubscription subscription); Task SubscribeAsync(PushSubscription subscription);
Task SendNotificationAsync(string message, string? url = null); Task SendNotificationAsync(string message, string? url = null);
} }
@@ -32,7 +33,7 @@ public class NotificationService(
return Task.FromResult(GetSettings().PublicKey); return Task.FromResult(GetSettings().PublicKey);
} }
public async Task SubscribeAsync(Entity.PushSubscription subscription) public async Task SubscribeAsync(PushSubscription subscription)
{ {
var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint); var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint);
if (existing != null) if (existing != null)
@@ -61,7 +62,7 @@ public class NotificationService(
var webPushClient = new WebPushClient(); var webPushClient = new WebPushClient();
var subscriptions = await subscriptionRepo.GetAllAsync(); var subscriptions = await subscriptionRepo.GetAllAsync();
var payload = System.Text.Json.JsonSerializer.Serialize(new var payload = JsonSerializer.Serialize(new
{ {
title = "System Notification", title = "System Notification",
body = message, body = message,
@@ -78,7 +79,7 @@ public class NotificationService(
} }
catch (WebPushException ex) catch (WebPushException ex)
{ {
if (ex.StatusCode == System.Net.HttpStatusCode.Gone || ex.StatusCode == System.Net.HttpStatusCode.NotFound) if (ex.StatusCode == HttpStatusCode.Gone || ex.StatusCode == HttpStatusCode.NotFound)
{ {
await subscriptionRepo.DeleteAsync(sub.Id); await subscriptionRepo.DeleteAsync(sub.Id);
} }

View File

@@ -1,61 +0,0 @@
using Microsoft.Extensions.Hosting;
namespace Service;
/// <summary>
/// 周期性账单后台服务
/// </summary>
public class PeriodicBillBackgroundService(
IServiceProvider serviceProvider,
ILogger<PeriodicBillBackgroundService> logger
) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("周期性账单后台服务已启动");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var now = DateTime.Now;
// 计算下次执行时间每天早上6点
var nextRun = now.Date.AddHours(6);
if (now >= nextRun)
{
nextRun = nextRun.AddDays(1);
}
var delay = nextRun - now;
logger.LogInformation("下次执行周期性账单检查时间: {NextRun}, 延迟: {Delay}",
nextRun.ToString("yyyy-MM-dd HH:mm:ss"), delay);
await Task.Delay(delay, stoppingToken);
if (stoppingToken.IsCancellationRequested)
break;
// 执行周期性账单检查
using (var scope = serviceProvider.CreateScope())
{
var periodicService = scope.ServiceProvider.GetRequiredService<ITransactionPeriodicService>();
await periodicService.ExecutePeriodicBillsAsync();
}
}
catch (OperationCanceledException)
{
logger.LogInformation("周期性账单后台服务已取消");
break;
}
catch (Exception ex)
{
logger.LogError(ex, "周期性账单后台服务执行出错");
// 出错后等待1小时再重试
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
logger.LogInformation("周期性账单后台服务已停止");
}
}

View File

@@ -5,6 +5,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="MailKit" /> <PackageReference Include="MailKit" />
<PackageReference Include="MimeKit" /> <PackageReference Include="MimeKit" />
<PackageReference Include="Microsoft.Extensions.Configuration" /> <PackageReference Include="Microsoft.Extensions.Configuration" />

View File

@@ -1,4 +1,4 @@
namespace Service; namespace Service.Transaction;
/// <summary> /// <summary>
/// 周期性账单服务接口 /// 周期性账单服务接口
@@ -31,10 +31,10 @@ public class TransactionPeriodicService(
try try
{ {
logger.LogInformation("开始执行周期性账单检查..."); logger.LogInformation("开始执行周期性账单检查...");
var pendingBills = await periodicRepository.GetPendingPeriodicBillsAsync(); var pendingBills = await periodicRepository.GetPendingPeriodicBillsAsync();
var billsList = pendingBills.ToList(); var billsList = pendingBills.ToList();
logger.LogInformation("找到 {Count} 条需要执行的周期性账单", billsList.Count); logger.LogInformation("找到 {Count} 条需要执行的周期性账单", billsList.Count);
foreach (var bill in billsList) foreach (var bill in billsList)
@@ -61,10 +61,10 @@ public class TransactionPeriodicService(
}; };
var success = await transactionRepository.AddAsync(transaction); var success = await transactionRepository.AddAsync(transaction);
if (success) if (success)
{ {
logger.LogInformation("成功创建周期性账单交易记录: {Reason}, 金额: {Amount}", logger.LogInformation("成功创建周期性账单交易记录: {Reason}, 金额: {Amount}",
bill.Reason, bill.Amount); bill.Reason, bill.Amount);
// 创建未读消息 // 创建未读消息
@@ -80,8 +80,8 @@ public class TransactionPeriodicService(
var now = DateTime.Now; var now = DateTime.Now;
var nextTime = CalculateNextExecuteTime(bill, now); var nextTime = CalculateNextExecuteTime(bill, now);
await periodicRepository.UpdateExecuteTimeAsync(bill.Id, now, nextTime); await periodicRepository.UpdateExecuteTimeAsync(bill.Id, now, nextTime);
logger.LogInformation("周期性账单 {Id} 下次执行时间: {NextTime}", logger.LogInformation("周期性账单 {Id} 下次执行时间: {NextTime}",
bill.Id, nextTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无"); bill.Id, nextTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无");
} }
else else
@@ -108,8 +108,13 @@ public class TransactionPeriodicService(
/// </summary> /// </summary>
private bool ShouldExecuteToday(TransactionPeriodic bill) private bool ShouldExecuteToday(TransactionPeriodic bill)
{ {
if (!bill.IsEnabled)
{
return false;
}
var today = DateTime.Today; var today = DateTime.Today;
// 如果从未执行过,需要执行 // 如果从未执行过,需要执行
if (bill.LastExecuteTime == null) if (bill.LastExecuteTime == null)
{ {
@@ -144,7 +149,7 @@ public class TransactionPeriodicService(
var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
.Where(d => d >= 0 && d <= 6) .Where(d => d is >= 0 and <= 6)
.ToList(); .ToList();
return executeDays.Contains(dayOfWeek); return executeDays.Contains(dayOfWeek);
@@ -160,7 +165,7 @@ public class TransactionPeriodicService(
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
.Where(d => d >= 1 && d <= 31) .Where(d => d is >= 1 and <= 31)
.ToList(); .ToList();
// 如果当前为月末,且配置中有大于当月天数的日期,则也执行 // 如果当前为月末,且配置中有大于当月天数的日期,则也执行
@@ -223,7 +228,7 @@ public class TransactionPeriodicService(
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
.Where(d => d >= 0 && d <= 6) .Where(d => d is >= 0 and <= 6)
.OrderBy(d => d) .OrderBy(d => d)
.ToList(); .ToList();
@@ -231,7 +236,7 @@ public class TransactionPeriodicService(
return null; return null;
var currentDayOfWeek = (int)baseTime.DayOfWeek; var currentDayOfWeek = (int)baseTime.DayOfWeek;
// 找下一个执行日 // 找下一个执行日
var nextDay = executeDays.FirstOrDefault(d => d > currentDayOfWeek); var nextDay = executeDays.FirstOrDefault(d => d > currentDayOfWeek);
if (nextDay > 0) if (nextDay > 0)
@@ -239,7 +244,7 @@ public class TransactionPeriodicService(
var daysToAdd = nextDay - currentDayOfWeek; var daysToAdd = nextDay - currentDayOfWeek;
return baseTime.Date.AddDays(daysToAdd); return baseTime.Date.AddDays(daysToAdd);
} }
// 下周的第一个执行日 // 下周的第一个执行日
var firstDay = executeDays.First(); var firstDay = executeDays.First();
var daysUntilNextWeek = 7 - currentDayOfWeek + firstDay; var daysUntilNextWeek = 7 - currentDayOfWeek + firstDay;
@@ -253,7 +258,7 @@ public class TransactionPeriodicService(
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
.Where(d => d >= 1 && d <= 31) .Where(d => d is >= 1 and <= 31)
.OrderBy(d => d) .OrderBy(d => d)
.ToList(); .ToList();
@@ -288,7 +293,7 @@ public class TransactionPeriodicService(
var currentQuarterStartMonth = ((baseTime.Month - 1) / 3) * 3 + 1; var currentQuarterStartMonth = ((baseTime.Month - 1) / 3) * 3 + 1;
var nextQuarterStartMonth = currentQuarterStartMonth + 3; var nextQuarterStartMonth = currentQuarterStartMonth + 3;
var nextQuarterYear = baseTime.Year; var nextQuarterYear = baseTime.Year;
if (nextQuarterStartMonth > 12) if (nextQuarterStartMonth > 12)
{ {
nextQuarterStartMonth = 1; nextQuarterStartMonth = 1;
@@ -313,7 +318,7 @@ public class TransactionPeriodicService(
// 处理闰年情况 // 处理闰年情况
var daysInYear = DateTime.IsLeapYear(nextYear) ? 366 : 365; var daysInYear = DateTime.IsLeapYear(nextYear) ? 366 : 365;
var actualDay = Math.Min(dayOfYear, daysInYear); var actualDay = Math.Min(dayOfYear, daysInYear);
return new DateTime(nextYear, 1, 1).AddDays(actualDay - 1); return new DateTime(nextYear, 1, 1).AddDays(actualDay - 1);
} }
} }

View File

@@ -0,0 +1,350 @@
namespace Service.Transaction;
public interface ITransactionStatisticsService
{
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null);
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month);
Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, 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);
Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10);
Task<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
DateTime startDate,
DateTime endDate,
TransactionType type,
IEnumerable<string> classifies,
bool groupByMonth = false);
Task<Dictionary<(string, TransactionType), decimal>> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime);
}
public class TransactionStatisticsService(
ITransactionRecordRepository transactionRepository
) : ITransactionStatisticsService
{
public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null)
{
// 当 month=0 时,表示查询整年数据
DateTime startDate;
DateTime endDate;
if (month == 0)
{
// 查询整年1月1日至12月31日
startDate = new DateTime(year, 1, 1);
endDate = new DateTime(year, 12, 31).AddDays(1); // 包含12月31日
}
else
{
// 查询指定月份
startDate = new DateTime(year, month, 1);
endDate = startDate.AddMonths(1);
}
return await GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify);
}
public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null)
{
var records = await transactionRepository.QueryAsync(
startDate: startDate,
endDate: endDate,
pageSize: int.MaxValue);
return records
.GroupBy(t => t.OccurredAt.ToString("yyyy-MM-dd"))
.ToDictionary(
g => g.Key,
g =>
{
var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount));
var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount));
var saving = 0m;
if (!string.IsNullOrEmpty(savingClassify))
{
saving = g.Where(t => savingClassify.Split(',').Contains(t.Classify)).Sum(t => Math.Abs(t.Amount));
}
return (count: g.Count(), expense, income, saving);
}
);
}
public async Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month)
{
var records = await transactionRepository.QueryAsync(
year: year,
month: month,
pageSize: int.MaxValue);
var statistics = new MonthlyStatistics
{
Year = year,
Month = 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(
year: year,
month: month,
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>();
for (var i = 0; i < monthCount; i++)
{
var targetYear = startYear;
var targetMonth = startMonth + i;
while (targetMonth > 12)
{
targetMonth -= 12;
targetYear++;
}
var records = await transactionRepository.QueryAsync(
year: targetYear,
month: targetMonth,
pageSize: int.MaxValue);
var expense = records.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount));
var income = records.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount));
trends.Add(new TrendStatistics
{
Year = targetYear,
Month = targetMonth,
Expense = expense,
Income = income,
Balance = income - expense
});
}
return trends;
}
public async Task<(List<ReasonGroupDto> list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20)
{
var records = await transactionRepository.QueryAsync(
pageSize: int.MaxValue);
var unclassifiedRecords = records
.Where(t => !string.IsNullOrEmpty(t.Reason) && string.IsNullOrEmpty(t.Classify))
.GroupBy(t => t.Reason)
.Select(g => new
{
Reason = g.Key,
Count = g.Count(),
TotalAmount = g.Sum(r => r.Amount),
SampleType = g.First().Type,
SampleClassify = g.First().Classify,
TransactionIds = g.Select(r => r.Id).ToList()
})
.OrderByDescending(g => Math.Abs(g.TotalAmount))
.ToList();
var total = unclassifiedRecords.Count;
var pagedGroups = unclassifiedRecords
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.Select(g => new ReasonGroupDto
{
Reason = g.Reason,
Count = g.Count,
SampleType = g.SampleType,
SampleClassify = g.SampleClassify,
TransactionIds = g.TransactionIds,
TotalAmount = Math.Abs(g.TotalAmount)
})
.ToList();
return (pagedGroups, total);
}
public async Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10)
{
if (keywords.Count == 0)
{
return [];
}
var candidates = await transactionRepository.GetClassifiedByKeywordsAsync(keywords, limit: int.MaxValue);
var scoredResults = candidates
.Select(record =>
{
var matchedCount = keywords.Count(keyword => record.Reason.Contains(keyword, StringComparison.OrdinalIgnoreCase));
var matchRate = (double)matchedCount / keywords.Count;
var exactMatchBonus = keywords.Any(k => record.Reason.Equals(k, StringComparison.OrdinalIgnoreCase)) ? 0.2 : 0.0;
var avgKeywordLength = keywords.Average(k => k.Length);
var lengthSimilarity = 1.0 - Math.Min(1.0, Math.Abs(record.Reason.Length - avgKeywordLength) / Math.Max(record.Reason.Length, avgKeywordLength));
var lengthBonus = lengthSimilarity * 0.1;
var score = matchRate + exactMatchBonus + lengthBonus;
return (record, score);
})
.Where(x => x.score >= minMatchRate)
.OrderByDescending(x => x.score)
.ThenByDescending(x => x.record.OccurredAt)
.Take(limit)
.ToList();
return scoredResults;
}
public async Task<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
DateTime startDate,
DateTime endDate,
TransactionType type,
IEnumerable<string> classifies,
bool groupByMonth = false)
{
var records = await transactionRepository.QueryAsync(
startDate: startDate,
endDate: endDate,
type: type,
classifies: classifies.ToArray(),
pageSize: int.MaxValue);
if (groupByMonth)
{
return records
.GroupBy(t => new DateTime(t.OccurredAt.Year, t.OccurredAt.Month, 1))
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
}
return records
.GroupBy(t => t.OccurredAt.Date)
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
}
public async Task<Dictionary<(string, TransactionType), decimal>> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime)
{
var records = await transactionRepository.QueryAsync(
startDate: startTime,
endDate: endTime,
pageSize: int.MaxValue);
return records
.GroupBy(t => new { t.Classify, t.Type })
.ToDictionary(g => (g.Key.Classify, g.Key.Type), g => g.Sum(t => t.Amount));
}
}
public record ReasonGroupDto
{
public string Reason { get; set; } = string.Empty;
public int Count { get; set; }
public TransactionType SampleType { get; set; }
public string SampleClassify { get; set; } = string.Empty;
public List<long> TransactionIds { get; set; } = [];
public decimal TotalAmount { get; set; }
}
public record MonthlyStatistics
{
public int Year { get; set; }
public int Month { get; set; }
public decimal TotalExpense { get; set; }
public decimal TotalIncome { get; set; }
public decimal Balance { get; set; }
public int ExpenseCount { get; set; }
public int IncomeCount { get; set; }
public int TotalCount { get; set; }
}
public record CategoryStatistics
{
public string Classify { get; set; } = string.Empty;
public decimal Amount { get; set; }
public int Count { get; set; }
public decimal Percent { get; set; }
}
public record TrendStatistics
{
public int Year { get; set; }
public int Month { get; set; }
public decimal Expense { get; set; }
public decimal Income { get; set; }
public decimal Balance { get; set; }
}

View File

@@ -0,0 +1,130 @@
# 版本切换功能实现总结
## 实现概述
在设置的开发者选项中添加了版本切换功能,用户可以在 V1 和 V2 版本之间切换。
## 修改的文件
### 1. Web/src/stores/version.js (新增)
- 创建 Pinia store 管理版本状态
- 使用 localStorage 持久化版本选择
- 提供 `setVersion()``isV2()` 方法
### 2. Web/src/views/SettingView.vue (修改)
- 在开发者选项中添加"切换版本"选项
- 显示当前版本V1/V2
- 实现版本切换对话框
- 实现版本切换后的路由跳转逻辑
### 3. Web/src/router/index.js (修改)
- 引入 version store
- 在路由守卫中添加版本路由重定向逻辑
- V2 模式下自动跳转到 V2 路由(如果存在)
- V1 模式下自动跳转到 V1 路由(如果在 V2 路由)
## 核心功能
1. **版本选择界面**
- 设置页面显示当前版本
- 点击弹出对话框,选择 V1 或 V2
- 切换成功后显示提示信息
2. **智能路由跳转**
- 选择 V2 后,如果当前路由有 V2 版本,自动跳转
- 选择 V1 后,如果当前在 V2 路由,自动跳转到 V1
- 没有对应版本时,保持当前路由不变
3. **路由守卫保护**
- 每次路由跳转时检查版本设置
- 自动重定向到正确版本的路由
- 保留 query 和 params 参数
4. **状态持久化**
- 版本选择保存在 localStorage
- 刷新页面后版本设置保持不变
## V2 路由命名规范
V2 路由必须遵循命名规范:`原路由名-v2`
示例:
- V1: `calendar` → V2: `calendar-v2`
- V1: `budget` → V2: `budget-v2`
## 当前支持的 V2 路由
- `calendar``calendar-v2` (CalendarV2.vue)
## 测试验证
- ✅ ESLint 检查通过(无错误)
- ✅ 构建成功pnpm build
- ✅ 所有修改文件符合项目代码规范
## 使用示例
### 用户操作流程
1. 进入"设置"页面
2. 滚动到"开发者"分组
3. 点击"切换版本"(当前版本显示在右侧)
4. 选择"V1"或"V2"
5. 系统自动跳转到对应版本的路由
### 开发者添加新 V2 路由
```javascript
// router/index.js
{
path: '/xxx-v2',
name: 'xxx-v2',
component: () => import('../views/XxxViewV2.vue'),
meta: { requiresAuth: true }
}
```
添加后即可自动支持版本切换。
## 技术细节
### 版本检测逻辑
```javascript
// 在路由守卫中
if (versionStore.isV2()) {
// 尝试跳转到 V2 路由
const v2RouteName = `${routeName}-v2`
if (存在 v2Route) {
跳转到 v2Route
} else {
保持当前路由
}
}
```
### 版本状态管理
```javascript
// stores/version.js
const currentVersion = ref(localStorage.getItem('app-version') || 'v1')
const setVersion = (version) => {
currentVersion.value = version
localStorage.setItem('app-version', version)
}
```
## 注意事项
1. V2 路由必须按照 `xxx-v2` 命名规范
2. 如果页面没有 V2 版本,切换后会保持在 V1 版本
3. 路由守卫会自动处理所有版本相关的路由跳转
4. 版本状态持久化在 localStorage 中
## 后续改进建议
1. 可以在 UI 上添加更明显的版本标识
2. 可以在无 V2 路由时给出提示
3. 可以添加版本切换的动画效果
4. 可以为不同版本设置不同的主题样式

143
Web/VERSION_SWITCH_TEST.md Normal file
View File

@@ -0,0 +1,143 @@
# 版本切换功能测试文档
## 功能说明
在设置的开发者选项中添加了版本切换功能,用户可以在 V1 和 V2 版本之间切换。当选择 V2 时,如果有对应的 V2 路由则自动跳转,否则保持当前路由。
## 实现文件
1. **Store**: `Web/src/stores/version.js` - 版本状态管理
2. **View**: `Web/src/views/SettingView.vue` - 设置页面添加版本切换入口
3. **Router**: `Web/src/router/index.js` - 路由守卫实现版本路由重定向
## 功能特性
- ✅ 版本状态持久化存储localStorage
- ✅ 设置页面显示当前版本V1/V2
- ✅ 点击弹出对话框选择版本
- ✅ 自动检测并跳转到对应版本路由
- ✅ 如果没有对应版本路由,保持当前路由
- ✅ 路由守卫自动处理版本路由
## 测试步骤
### 1. 基础功能测试
1. 启动应用并登录
2. 进入"设置"页面
3. 找到"开发者"分组下的"切换版本"选项
4. 当前版本应显示为 "V1"(首次使用)
### 2. 切换到 V2 测试
1. 点击"切换版本"
2. 弹出对话框,显示"选择版本"标题
3. 对话框有两个按钮:"V1"(取消按钮)和"V2"(确认按钮)
4. 点击"V2"按钮
5. 应显示提示"已切换到 V2"
6. "切换版本"选项的值应更新为 "V2"
### 3. V2 路由跳转测试
#### 测试有 V2 路由的情况(日历页面)
1. 确保当前版本为 V2
2. 点击导航栏的"日历"(路由名:`calendar`
3. 应自动跳转到 `calendar-v2`CalendarV2.vue
4. 地址栏 URL 应为 `/calendar-v2`
#### 测试没有 V2 路由的情况
1. 确保当前版本为 V2
2. 点击导航栏的"账单分析"(路由名:`bill-analysis`
3. 应保持在 `bill-analysis` 路由(没有 v2 版本)
4. 地址栏 URL 应为 `/bill-analysis`
### 4. 切换回 V1 测试
1. 当前版本为 V2`calendar-v2` 页面
2. 进入"设置"页面,点击"切换版本"
3. 点击"V1"按钮
4. 应显示提示"已切换到 V1"
5. 如果当前在 V2 路由(如 `calendar-v2`),应自动跳转到 V1 路由(`calendar`
6. 地址栏 URL 应为 `/calendar`
### 5. 持久化测试
1. 切换到 V2 版本
2. 刷新页面
3. 重新登录后,进入"设置"页面
4. "切换版本"选项应仍显示 "V2"
5. 访问有 V2 路由的页面,应自动跳转到 V2 版本
### 6. 路由守卫测试
#### 直接访问 V2 路由V1 模式下)
1. 确保当前版本为 V1
2. 在地址栏直接输入 `/calendar-v2`
3. 应自动重定向到 `/calendar`
#### 直接访问 V1 路由V2 模式下)
1. 确保当前版本为 V2
2. 在地址栏直接输入 `/calendar`
3. 应自动重定向到 `/calendar-v2`
## 当前支持 V2 的路由
- `calendar``calendar-v2` (CalendarV2.vue)
## 代码验证
### 版本 Store 检查
```javascript
// 打开浏览器控制台
const versionStore = useVersionStore()
console.log(versionStore.currentVersion) // 应输出 'v1' 或 'v2'
console.log(versionStore.isV2()) // 应输出 true 或 false
```
### LocalStorage 检查
```javascript
// 打开浏览器控制台
console.log(localStorage.getItem('app-version')) // 应输出 'v1' 或 'v2'
```
## 预期结果
- ✅ 所有路由跳转正常
- ✅ 版本切换提示正常显示
- ✅ 版本状态持久化正常
- ✅ 路由守卫正常工作
- ✅ 没有控制台错误
- ✅ UI 响应流畅
## 潜在问题
1. 如果用户在 V2 路由页面直接切换到 V1可能会出现短暂的页面重载
2. 某些页面可能没有 V2 版本,切换后会保持在 V1 版本
## 后续扩展
如需添加更多 V2 路由,只需:
1. 创建新的 Vue 组件(如 `XXXViewV2.vue`
2.`router/index.js` 中添加路由,命名格式为 `原路由名-v2`
3. 路由守卫会自动处理版本切换逻辑
## 示例:添加新的 V2 路由
```javascript
// router/index.js
{
path: '/budget-v2',
name: 'budget-v2',
component: () => import('../views/BudgetViewV2.vue'),
meta: { requiresAuth: true }
}
```
添加后,当用户选择 V2 版本并访问 `/budget` 时,会自动跳转到 `/budget-v2`

View File

@@ -10,7 +10,7 @@
<!-- iOS Safari --> <!-- iOS Safari -->
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="账单管理"> <meta name="apple-mobile-web-app-title" content="账单管理">
<link rel="apple-touch-icon" href="/icons/icon-152x152.svg"> <link rel="apple-touch-icon" href="/icons/icon-152x152.svg">
<link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.svg"> <link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.svg">
@@ -24,10 +24,11 @@
<!-- Android Chrome --> <!-- Android Chrome -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1989fa"> <meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#09090B" media="(prefers-color-scheme: dark)">
<!-- Microsoft --> <!-- Microsoft -->
<meta name="msapplication-TileColor" content="#1989fa"> <meta name="msapplication-TileColor" content="#FFFFFF">
<meta name="msapplication-TileImage" content="/icons/icon-144x144.png"> <meta name="msapplication-TileImage" content="/icons/icon-144x144.png">
<meta name="description" content="个人账单管理与邮件解析系统"> <meta name="description" content="个人账单管理与邮件解析系统">

View File

@@ -30,6 +30,7 @@
"eslint-plugin-vue": "~10.5.1", "eslint-plugin-vue": "~10.5.1",
"globals": "^16.5.0", "globals": "^16.5.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"sass-embedded": "^1.97.3",
"vite": "^7.2.4", "vite": "^7.2.4",
"vite-plugin-vue-devtools": "^8.0.5" "vite-plugin-vue-devtools": "^8.0.5"
} }

493
Web/pnpm-lock.yaml generated
View File

@@ -35,7 +35,7 @@ importers:
version: 9.39.2 version: 9.39.2
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.3(vite@7.3.0)(vue@3.5.26) version: 6.0.3(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))(vue@3.5.26)
'@vue/eslint-config-prettier': '@vue/eslint-config-prettier':
specifier: ^10.2.0 specifier: ^10.2.0
version: 10.2.0(eslint@9.39.2)(prettier@3.6.2) version: 10.2.0(eslint@9.39.2)(prettier@3.6.2)
@@ -51,12 +51,15 @@ importers:
prettier: prettier:
specifier: 3.6.2 specifier: 3.6.2
version: 3.6.2 version: 3.6.2
sass-embedded:
specifier: ^1.97.3
version: 1.97.3
vite: vite:
specifier: ^7.2.4 specifier: ^7.2.4
version: 7.3.0 version: 7.3.0(sass-embedded@1.97.3)(sass@1.97.3)
vite-plugin-vue-devtools: vite-plugin-vue-devtools:
specifier: ^8.0.5 specifier: ^8.0.5
version: 8.0.5(vite@7.3.0)(vue@3.5.26) version: 8.0.5(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))(vue@3.5.26)
packages: packages:
@@ -200,6 +203,9 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@bufbuild/protobuf@2.11.0':
resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==}
'@esbuild/aix-ppc64@0.27.2': '@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -426,6 +432,88 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.6':
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.6':
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.6':
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.6':
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.6':
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.6':
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.6':
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@pkgr/core@0.2.9': '@pkgr/core@0.2.9':
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -470,67 +558,56 @@ packages:
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.54.0': '@rollup/rollup-linux-arm-musleabihf@4.54.0':
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.54.0': '@rollup/rollup-linux-arm64-gnu@4.54.0':
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.54.0': '@rollup/rollup-linux-arm64-musl@4.54.0':
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.54.0': '@rollup/rollup-linux-loong64-gnu@4.54.0':
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.54.0': '@rollup/rollup-linux-ppc64-gnu@4.54.0':
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.54.0': '@rollup/rollup-linux-riscv64-gnu@4.54.0':
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.54.0': '@rollup/rollup-linux-riscv64-musl@4.54.0':
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.54.0': '@rollup/rollup-linux-s390x-gnu@4.54.0':
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.54.0': '@rollup/rollup-linux-x64-gnu@4.54.0':
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.54.0': '@rollup/rollup-linux-x64-musl@4.54.0':
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.54.0': '@rollup/rollup-openharmony-arm64@4.54.0':
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
@@ -722,6 +799,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -729,6 +810,9 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorjs.io@0.5.2:
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -786,6 +870,10 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1019,6 +1107,9 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immutable@5.1.4:
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
import-fresh@3.3.1: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1143,6 +1234,9 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-releases@2.0.27: node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@@ -1233,6 +1327,10 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1249,6 +1347,123 @@ packages:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
sass-embedded-all-unknown@1.97.3:
resolution: {integrity: sha512-t6N46NlPuXiY3rlmG6/+1nwebOBOaLFOOVqNQOC2cJhghOD4hh2kHNQQTorCsbY9S1Kir2la1/XLBwOJfui0xg==}
cpu: ['!arm', '!arm64', '!riscv64', '!x64']
sass-embedded-android-arm64@1.97.3:
resolution: {integrity: sha512-aiZ6iqiHsUsaDx0EFbbmmA0QgxicSxVVN3lnJJ0f1RStY0DthUkquGT5RJ4TPdaZ6ebeJWkboV4bra+CP766eA==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [android]
sass-embedded-android-arm@1.97.3:
resolution: {integrity: sha512-cRTtf/KV/q0nzGZoUzVkeIVVFv3L/tS1w4WnlHapphsjTXF/duTxI8JOU1c/9GhRPiMdfeXH7vYNcMmtjwX7jg==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [android]
sass-embedded-android-riscv64@1.97.3:
resolution: {integrity: sha512-zVEDgl9JJodofGHobaM/q6pNETG69uuBIGQHRo789jloESxxZe82lI3AWJQuPmYCOG5ElfRthqgv89h3gTeLYA==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [android]
sass-embedded-android-x64@1.97.3:
resolution: {integrity: sha512-3ke0le7ZKepyXn/dKKspYkpBC0zUk/BMciyP5ajQUDy4qJwobd8zXdAq6kOkdiMB+d9UFJOmEkvgFJHl3lqwcw==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [android]
sass-embedded-darwin-arm64@1.97.3:
resolution: {integrity: sha512-fuqMTqO4gbOmA/kC5b9y9xxNYw6zDEyfOtMgabS7Mz93wimSk2M1quQaTJnL98Mkcsl2j+7shNHxIS/qpcIDDA==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [darwin]
sass-embedded-darwin-x64@1.97.3:
resolution: {integrity: sha512-b/2RBs/2bZpP8lMkyZ0Px0vkVkT8uBd0YXpOwK7iOwYkAT8SsO4+WdVwErsqC65vI5e1e5p1bb20tuwsoQBMVA==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [darwin]
sass-embedded-linux-arm64@1.97.3:
resolution: {integrity: sha512-IP1+2otCT3DuV46ooxPaOKV1oL5rLjteRzf8ldZtfIEcwhSgSsHgA71CbjYgLEwMY9h4jeal8Jfv3QnedPvSjg==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-arm@1.97.3:
resolution: {integrity: sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-musl-arm64@1.97.3:
resolution: {integrity: sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-musl-arm@1.97.3:
resolution: {integrity: sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-musl-riscv64@1.97.3:
resolution: {integrity: sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-musl-x64@1.97.3:
resolution: {integrity: sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-linux-riscv64@1.97.3:
resolution: {integrity: sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-x64@1.97.3:
resolution: {integrity: sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-unknown-all@1.97.3:
resolution: {integrity: sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q==}
os: ['!android', '!darwin', '!linux', '!win32']
sass-embedded-win32-arm64@1.97.3:
resolution: {integrity: sha512-RDGtRS1GVvQfMGAmVXNxYiUOvPzn9oO1zYB/XUM9fudDRnieYTcUytpNTQZLs6Y1KfJxgt5Y+giRceC92fT8Uw==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [win32]
sass-embedded-win32-x64@1.97.3:
resolution: {integrity: sha512-SFRa2lED9UEwV6vIGeBXeBOLKF+rowF3WmNfb/BzhxmdAsKofCXrJ8ePW7OcDVrvNEbTOGwhsReIsF5sH8fVaw==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [win32]
sass-embedded@1.97.3:
resolution: {integrity: sha512-eKzFy13Nk+IRHhlAwP3sfuv+PzOrvzUkwJK2hdoCKYcWGSdmwFpeGpWmyewdw8EgBnsKaSBtgf/0b2K635ecSA==}
engines: {node: '>=16.0.0'}
hasBin: true
sass@1.97.3:
resolution: {integrity: sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==}
engines: {node: '>=14.0.0'}
hasBin: true
semver@6.3.1: semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
@@ -1290,6 +1505,18 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
sync-child-process@1.0.2:
resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==}
engines: {node: '>=16.0.0'}
sync-message-port@1.2.0:
resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==}
engines: {node: '>=16.0.0'}
synckit@0.11.11: synckit@0.11.11:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
@@ -1330,6 +1557,9 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.0.0 vue: ^3.0.0
varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
vite-dev-rpc@1.1.0: vite-dev-rpc@1.1.0:
resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==} resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==}
peerDependencies: peerDependencies:
@@ -1640,6 +1870,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@bufbuild/protobuf@2.11.0': {}
'@esbuild/aix-ppc64@0.27.2': '@esbuild/aix-ppc64@0.27.2':
optional: true optional: true
@@ -1794,6 +2026,67 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@parcel/watcher-android-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-x64@2.5.6':
optional: true
'@parcel/watcher-freebsd-x64@2.5.6':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.6':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.6':
optional: true
'@parcel/watcher-win32-arm64@2.5.6':
optional: true
'@parcel/watcher-win32-ia32@2.5.6':
optional: true
'@parcel/watcher-win32-x64@2.5.6':
optional: true
'@parcel/watcher@2.5.6':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.3
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
'@parcel/watcher-darwin-x64': 2.5.6
'@parcel/watcher-freebsd-x64': 2.5.6
'@parcel/watcher-linux-arm-glibc': 2.5.6
'@parcel/watcher-linux-arm-musl': 2.5.6
'@parcel/watcher-linux-arm64-glibc': 2.5.6
'@parcel/watcher-linux-arm64-musl': 2.5.6
'@parcel/watcher-linux-x64-glibc': 2.5.6
'@parcel/watcher-linux-x64-musl': 2.5.6
'@parcel/watcher-win32-arm64': 2.5.6
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
optional: true
'@pkgr/core@0.2.9': {} '@pkgr/core@0.2.9': {}
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
@@ -1876,10 +2169,10 @@ snapshots:
dependencies: dependencies:
vue: 3.5.26 vue: 3.5.26
'@vitejs/plugin-vue@6.0.3(vite@7.3.0)(vue@3.5.26)': '@vitejs/plugin-vue@6.0.3(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))(vue@3.5.26)':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53 '@rolldown/pluginutils': 1.0.0-beta.53
vite: 7.3.0 vite: 7.3.0(sass-embedded@1.97.3)(sass@1.97.3)
vue: 3.5.26 vue: 3.5.26
'@vue/babel-helper-vue-transform-on@1.5.0': {} '@vue/babel-helper-vue-transform-on@1.5.0': {}
@@ -1947,14 +2240,14 @@ snapshots:
dependencies: dependencies:
'@vue/devtools-kit': 7.7.9 '@vue/devtools-kit': 7.7.9
'@vue/devtools-core@8.0.5(vite@7.3.0)(vue@3.5.26)': '@vue/devtools-core@8.0.5(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))(vue@3.5.26)':
dependencies: dependencies:
'@vue/devtools-kit': 8.0.5 '@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5 '@vue/devtools-shared': 8.0.5
mitt: 3.0.1 mitt: 3.0.1
nanoid: 5.1.6 nanoid: 5.1.6
pathe: 2.0.3 pathe: 2.0.3
vite-hot-client: 2.1.0(vite@7.3.0) vite-hot-client: 2.1.0(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))
vue: 3.5.26 vue: 3.5.26
transitivePeerDependencies: transitivePeerDependencies:
- vite - vite
@@ -2090,12 +2383,19 @@ snapshots:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
optional: true
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
color-name@1.1.4: {} color-name@1.1.4: {}
colorjs.io@0.5.2: {}
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
@@ -2137,6 +2437,9 @@ snapshots:
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
detect-libc@2.1.2:
optional: true
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -2383,6 +2686,8 @@ snapshots:
ignore@5.3.2: {} ignore@5.3.2: {}
immutable@5.1.4: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@@ -2475,6 +2780,9 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
node-addon-api@7.1.1:
optional: true
node-releases@2.0.27: {} node-releases@2.0.27: {}
nth-check@2.1.1: nth-check@2.1.1:
@@ -2553,6 +2861,9 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
readdirp@4.1.2:
optional: true
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
rfdc@1.4.1: {} rfdc@1.4.1: {}
@@ -2587,6 +2898,106 @@ snapshots:
run-applescript@7.1.0: {} run-applescript@7.1.0: {}
rxjs@7.8.2:
dependencies:
tslib: 2.3.0
sass-embedded-all-unknown@1.97.3:
dependencies:
sass: 1.97.3
optional: true
sass-embedded-android-arm64@1.97.3:
optional: true
sass-embedded-android-arm@1.97.3:
optional: true
sass-embedded-android-riscv64@1.97.3:
optional: true
sass-embedded-android-x64@1.97.3:
optional: true
sass-embedded-darwin-arm64@1.97.3:
optional: true
sass-embedded-darwin-x64@1.97.3:
optional: true
sass-embedded-linux-arm64@1.97.3:
optional: true
sass-embedded-linux-arm@1.97.3:
optional: true
sass-embedded-linux-musl-arm64@1.97.3:
optional: true
sass-embedded-linux-musl-arm@1.97.3:
optional: true
sass-embedded-linux-musl-riscv64@1.97.3:
optional: true
sass-embedded-linux-musl-x64@1.97.3:
optional: true
sass-embedded-linux-riscv64@1.97.3:
optional: true
sass-embedded-linux-x64@1.97.3:
optional: true
sass-embedded-unknown-all@1.97.3:
dependencies:
sass: 1.97.3
optional: true
sass-embedded-win32-arm64@1.97.3:
optional: true
sass-embedded-win32-x64@1.97.3:
optional: true
sass-embedded@1.97.3:
dependencies:
'@bufbuild/protobuf': 2.11.0
colorjs.io: 0.5.2
immutable: 5.1.4
rxjs: 7.8.2
supports-color: 8.1.1
sync-child-process: 1.0.2
varint: 6.0.0
optionalDependencies:
sass-embedded-all-unknown: 1.97.3
sass-embedded-android-arm: 1.97.3
sass-embedded-android-arm64: 1.97.3
sass-embedded-android-riscv64: 1.97.3
sass-embedded-android-x64: 1.97.3
sass-embedded-darwin-arm64: 1.97.3
sass-embedded-darwin-x64: 1.97.3
sass-embedded-linux-arm: 1.97.3
sass-embedded-linux-arm64: 1.97.3
sass-embedded-linux-musl-arm: 1.97.3
sass-embedded-linux-musl-arm64: 1.97.3
sass-embedded-linux-musl-riscv64: 1.97.3
sass-embedded-linux-musl-x64: 1.97.3
sass-embedded-linux-riscv64: 1.97.3
sass-embedded-linux-x64: 1.97.3
sass-embedded-unknown-all: 1.97.3
sass-embedded-win32-arm64: 1.97.3
sass-embedded-win32-x64: 1.97.3
sass@1.97.3:
dependencies:
chokidar: 4.0.3
immutable: 5.1.4
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.5.6
optional: true
semver@6.3.1: {} semver@6.3.1: {}
semver@7.7.3: {} semver@7.7.3: {}
@@ -2617,6 +3028,16 @@ snapshots:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
sync-child-process@1.0.2:
dependencies:
sync-message-port: 1.2.0
sync-message-port@1.2.0: {}
synckit@0.11.11: synckit@0.11.11:
dependencies: dependencies:
'@pkgr/core': 0.2.9 '@pkgr/core': 0.2.9
@@ -2658,17 +3079,19 @@ snapshots:
'@vue/shared': 3.5.26 '@vue/shared': 3.5.26
vue: 3.5.26 vue: 3.5.26
vite-dev-rpc@1.1.0(vite@7.3.0): varint@6.0.0: {}
vite-dev-rpc@1.1.0(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3)):
dependencies: dependencies:
birpc: 2.9.0 birpc: 2.9.0
vite: 7.3.0 vite: 7.3.0(sass-embedded@1.97.3)(sass@1.97.3)
vite-hot-client: 2.1.0(vite@7.3.0) vite-hot-client: 2.1.0(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))
vite-hot-client@2.1.0(vite@7.3.0): vite-hot-client@2.1.0(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3)):
dependencies: dependencies:
vite: 7.3.0 vite: 7.3.0(sass-embedded@1.97.3)(sass@1.97.3)
vite-plugin-inspect@11.3.3(vite@7.3.0): vite-plugin-inspect@11.3.3(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3)):
dependencies: dependencies:
ansis: 4.2.0 ansis: 4.2.0
debug: 4.4.3 debug: 4.4.3
@@ -2678,26 +3101,26 @@ snapshots:
perfect-debounce: 2.0.0 perfect-debounce: 2.0.0
sirv: 3.0.2 sirv: 3.0.2
unplugin-utils: 0.3.1 unplugin-utils: 0.3.1
vite: 7.3.0 vite: 7.3.0(sass-embedded@1.97.3)(sass@1.97.3)
vite-dev-rpc: 1.1.0(vite@7.3.0) vite-dev-rpc: 1.1.0(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vite-plugin-vue-devtools@8.0.5(vite@7.3.0)(vue@3.5.26): vite-plugin-vue-devtools@8.0.5(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))(vue@3.5.26):
dependencies: dependencies:
'@vue/devtools-core': 8.0.5(vite@7.3.0)(vue@3.5.26) '@vue/devtools-core': 8.0.5(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))(vue@3.5.26)
'@vue/devtools-kit': 8.0.5 '@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5 '@vue/devtools-shared': 8.0.5
sirv: 3.0.2 sirv: 3.0.2
vite: 7.3.0 vite: 7.3.0(sass-embedded@1.97.3)(sass@1.97.3)
vite-plugin-inspect: 11.3.3(vite@7.3.0) vite-plugin-inspect: 11.3.3(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))
vite-plugin-vue-inspector: 5.3.2(vite@7.3.0) vite-plugin-vue-inspector: 5.3.2(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3))
transitivePeerDependencies: transitivePeerDependencies:
- '@nuxt/kit' - '@nuxt/kit'
- supports-color - supports-color
- vue - vue
vite-plugin-vue-inspector@5.3.2(vite@7.3.0): vite-plugin-vue-inspector@5.3.2(vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3)):
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5) '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5)
@@ -2708,11 +3131,11 @@ snapshots:
'@vue/compiler-dom': 3.5.26 '@vue/compiler-dom': 3.5.26
kolorist: 1.8.0 kolorist: 1.8.0
magic-string: 0.30.21 magic-string: 0.30.21
vite: 7.3.0 vite: 7.3.0(sass-embedded@1.97.3)(sass@1.97.3)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vite@7.3.0: vite@7.3.0(sass-embedded@1.97.3)(sass@1.97.3):
dependencies: dependencies:
esbuild: 0.27.2 esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -2722,6 +3145,8 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
sass: 1.97.3
sass-embedded: 1.97.3
vue-eslint-parser@10.2.0(eslint@9.39.2): vue-eslint-parser@10.2.0(eslint@9.39.2):
dependencies: dependencies:

View File

@@ -1,11 +1,11 @@
{ {
"name": "账单", "name": "账单",
"short_name": "账单", "short_name": "账单",
"description": "个人账单管理与邮件解析", "description": "个人账单管理与邮件解析",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#ffffff", "background_color": "#ffffff",
"theme_color": "#1989fa", "theme_color": "#ffffff",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"icons": [ "icons": [
{ {

View File

@@ -1,9 +1,31 @@
<template> <template>
<van-config-provider :theme="theme" class="app-provider"> <van-config-provider
:theme="theme"
class="app-provider"
>
<div class="app-root"> <div class="app-root">
<RouterView /> <router-view v-slot="{ Component }">
<van-tabbar v-show="showTabbar" v-model="active"> <keep-alive
<van-tabbar-item name="ccalendar" icon="notes" to="/calendar"> 日历 </van-tabbar-item> :include="cachedViews"
:max="8"
>
<component
:is="Component"
:key="route.name"
/>
</keep-alive>
</router-view>
<van-tabbar
v-show="showTabbar"
v-model="active"
>
<van-tabbar-item
name="ccalendar"
icon="notes"
to="/calendar"
>
日历
</van-tabbar-item>
<van-tabbar-item <van-tabbar-item
name="statistics" name="statistics"
icon="chart-trending-o" icon="chart-trending-o"
@@ -29,12 +51,28 @@
> >
预算 预算
</van-tabbar-item> </van-tabbar-item>
<van-tabbar-item name="setting" icon="setting" to="/setting"> 设置 </van-tabbar-item> <van-tabbar-item
name="setting"
icon="setting"
to="/setting"
>
设置
</van-tabbar-item>
</van-tabbar> </van-tabbar>
<GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess" /> <GlobalAddBill
v-if="isShowAddBill"
@success="handleAddTransactionSuccess"
/>
<div v-if="needRefresh" class="update-toast" @click="updateServiceWorker"> <div
<van-icon name="upgrade" class="update-icon" /> v-if="needRefresh"
class="update-toast"
@click="updateServiceWorker"
>
<van-icon
name="upgrade"
class="update-icon"
/>
<span>新版本可用点击刷新</span> <span>新版本可用点击刷新</span>
</div> </div>
</div> </div>
@@ -51,6 +89,15 @@ import '@/styles/common.css'
const messageStore = useMessageStore() const messageStore = useMessageStore()
// 定义需要缓存的页面组件名称
const cachedViews = ref([
'CalendarV2', // 日历V2页面
'CalendarView', // 日历V1页面
'StatisticsView', // 统计页面
'BalanceView', // 账单页面
'BudgetView' // 预算页面
])
const updateVh = () => { const updateVh = () => {
const vh = window.innerHeight const vh = window.innerHeight
document.documentElement.style.setProperty('--vh', `${vh}px`) document.documentElement.style.setProperty('--vh', `${vh}px`)
@@ -94,6 +141,7 @@ const showTabbar = computed(() => {
return ( return (
route.path === '/' || route.path === '/' ||
route.path === '/calendar' || route.path === '/calendar' ||
route.path === '/calendar-v2' ||
route.path === '/message' || route.path === '/message' ||
route.path === '/setting' || route.path === '/setting' ||
route.path === '/balance' || route.path === '/balance' ||
@@ -108,6 +156,8 @@ const theme = ref('light')
const updateTheme = () => { const updateTheme = () => {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
theme.value = isDark ? 'dark' : 'light' theme.value = isDark ? 'dark' : 'light'
// 在文档根元素上设置 data-theme 属性,使 CSS 变量生效
document.documentElement.setAttribute('data-theme', theme.value)
} }
// 监听系统主题变化 // 监听系统主题变化
@@ -137,6 +187,7 @@ const setActive = (path) => {
active.value = (() => { active.value = (() => {
switch (path) { switch (path) {
case '/calendar': case '/calendar':
case '/calendar-v2':
return 'ccalendar' return 'ccalendar'
case '/balance': case '/balance':
case '/message': case '/message':
@@ -149,11 +200,10 @@ const setActive = (path) => {
return 'statistics' return 'statistics'
} }
})() })()
console.log(active.value, path)
} }
const isShowAddBill = computed(() => { const isShowAddBill = computed(() => {
return route.path === '/' || route.path === '/balance' || route.path === '/message' return route.path === '/' || route.path === '/balance' || route.path === '/message' || route.path === '/calendar' || route.path === '/calendar-v2'
}) })
onUnmounted(() => { onUnmounted(() => {

59
Web/src/api/AGENTS.md Normal file
View File

@@ -0,0 +1,59 @@
# API CLIENTS KNOWLEDGE BASE
**Generated:** 2026-01-28
**Parent:** EmailBill/AGENTS.md
## OVERVIEW
Axios-based HTTP client modules for backend API integration with request/response interceptors.
## STRUCTURE
```
Web/src/api/
├── request.js # Base HTTP client setup
├── auth.js # Authentication API
├── budget.js # Budget management API
├── transactionRecord.js # Transaction CRUD API
├── transactionCategory.js # Category management
├── transactionPeriodic.js # Periodic transactions
├── statistics.js # Analytics API
├── message.js # Message API
├── notification.js # Push notifications
├── emailRecord.js # Email records
├── config.js # Configuration API
├── billImport.js # Bill import
├── log.js # Application logs
└── job.js # Background job management
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Base HTTP setup | request.js | Axios interceptors, error handling |
| Authentication | auth.js | Login, token management |
| Budget data | budget.js | Budget CRUD, statistics |
| Transactions | transactionRecord.js | Transaction operations |
| Categories | transactionCategory.js | Category management |
| Statistics | statistics.js | Analytics, reports |
| Notifications | notification.js | Push subscription handling |
## CONVENTIONS
- All functions return Promises with async/await
- Error handling via try/catch with user messages
- HTTP methods: get, post, put, delete mapping to REST
- Request/response data transformation
- Token-based authentication via headers
- Consistent error message format
## ANTI-PATTERNS (THIS LAYER)
- Never fetch directly without going through these modules
- Don't hardcode API endpoints (use environment variables)
- Avoid synchronous operations
- Don't duplicate request logic across components
- No business logic in API clients
## UNIQUE STYLES
- Chinese error messages for user feedback
- Automatic token refresh handling
- Request/response logging for debugging
- Paged query patterns for list endpoints
- File upload handling for imports

View File

@@ -4,7 +4,7 @@
* 获取预算列表 * 获取预算列表
* @param {string} referenceDate 参考日期 (可选) * @param {string} referenceDate 参考日期 (可选)
*/ */
export function getBudgetList(referenceDate) { export function getBudgetList (referenceDate) {
return request({ return request({
url: '/Budget/GetList', url: '/Budget/GetList',
method: 'get', method: 'get',
@@ -16,7 +16,7 @@ export function getBudgetList(referenceDate) {
* 创建预算 * 创建预算
* @param {object} data 预算数据 * @param {object} data 预算数据
*/ */
export function createBudget(data) { export function createBudget (data) {
return request({ return request({
url: '/Budget/Create', url: '/Budget/Create',
method: 'post', method: 'post',
@@ -28,7 +28,7 @@ export function createBudget(data) {
* 更新预算 * 更新预算
* @param {object} data 预算数据 * @param {object} data 预算数据
*/ */
export function updateBudget(data) { export function updateBudget (data) {
return request({ return request({
url: '/Budget/Update', url: '/Budget/Update',
method: 'post', method: 'post',
@@ -40,7 +40,7 @@ export function updateBudget(data) {
* 删除预算 * 删除预算
* @param {number} id 预算ID * @param {number} id 预算ID
*/ */
export function deleteBudget(id) { export function deleteBudget (id) {
return request({ return request({
url: `/Budget/DeleteById/${id}`, url: `/Budget/DeleteById/${id}`,
method: 'delete' method: 'delete'
@@ -52,7 +52,7 @@ export function deleteBudget(id) {
* @param {string} category 分类 (Expense/Income/Savings) * @param {string} category 分类 (Expense/Income/Savings)
* @param {string} referenceDate 参考日期 (可选) * @param {string} referenceDate 参考日期 (可选)
*/ */
export function getCategoryStats(category, referenceDate) { export function getCategoryStats (category, referenceDate) {
return request({ return request({
url: '/Budget/GetCategoryStats', url: '/Budget/GetCategoryStats',
method: 'get', method: 'get',
@@ -64,7 +64,7 @@ export function getCategoryStats(category, referenceDate) {
* @param {number} category 预算分类 * @param {number} category 预算分类
* @param {string} referenceDate 参考日期 * @param {string} referenceDate 参考日期
*/ */
export function getUncoveredCategories(category, referenceDate) { export function getUncoveredCategories (category, referenceDate) {
return request({ return request({
url: '/Budget/GetUncoveredCategories', url: '/Budget/GetUncoveredCategories',
method: 'get', method: 'get',
@@ -76,7 +76,7 @@ export function getUncoveredCategories(category, referenceDate) {
* 获取归档总结 * 获取归档总结
* @param {string} referenceDate 参考日期 * @param {string} referenceDate 参考日期
*/ */
export function getArchiveSummary(referenceDate) { export function getArchiveSummary (referenceDate) {
return request({ return request({
url: '/Budget/GetArchiveSummary', url: '/Budget/GetArchiveSummary',
method: 'get', method: 'get',
@@ -88,7 +88,7 @@ export function getArchiveSummary(referenceDate) {
* 更新归档总结 * 更新归档总结
* @param {object} data 数据 { referenceDate, summary } * @param {object} data 数据 { referenceDate, summary }
*/ */
export function updateArchiveSummary(data) { export function updateArchiveSummary (data) {
return request({ return request({
url: '/Budget/UpdateArchiveSummary', url: '/Budget/UpdateArchiveSummary',
method: 'post', method: 'post',
@@ -102,7 +102,7 @@ export function updateArchiveSummary(data) {
* @param {number} month 月份 * @param {number} month 月份
* @param {number} type 周期类型 (1:Month, 2:Year) * @param {number} type 周期类型 (1:Month, 2:Year)
*/ */
export function getSavingsBudget(year, month, type) { export function getSavingsBudget (year, month, type) {
return request({ return request({
url: '/Budget/GetSavingsBudget', url: '/Budget/GetSavingsBudget',
method: 'get', method: 'get',

View File

@@ -1,4 +1,4 @@
import request from './request' import request from './request'
/** /**
* 日志相关 API * 日志相关 API
@@ -12,6 +12,7 @@
* @param {string} [params.searchKeyword] - 搜索关键词 * @param {string} [params.searchKeyword] - 搜索关键词
* @param {string} [params.logLevel] - 日志级别 * @param {string} [params.logLevel] - 日志级别
* @param {string} [params.date] - 日期 (yyyyMMdd) * @param {string} [params.date] - 日期 (yyyyMMdd)
* @param {string} [params.className] - 类名
* @returns {Promise<{success: boolean, data: Array, total: number}>} * @returns {Promise<{success: boolean, data: Array, total: number}>}
*/ */
export const getLogList = (params = {}) => { export const getLogList = (params = {}) => {
@@ -32,3 +33,34 @@ export const getAvailableDates = () => {
method: 'get' method: 'get'
}) })
} }
/**
* 获取可用的类名列表
* @param {Object} params - 查询参数
* @param {string} [params.date] - 日期 (yyyyMMdd)
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getAvailableClassNames = (params = {}) => {
return request({
url: '/Log/GetAvailableClassNames',
method: 'get',
params
})
}
/**
* 根据请求ID查询关联日志
* @param {Object} params - 查询参数
* @param {string} params.requestId - 请求ID
* @param {number} [params.pageIndex=1] - 页码
* @param {number} [params.pageSize=50] - 每页条数
* @param {string} [params.date] - 日期 (yyyyMMdd)
* @returns {Promise<{success: boolean, data: Array, total: number}>}
*/
export const getLogsByRequestId = (params = {}) => {
return request({
url: '/Log/GetLogsByRequestId',
method: 'get',
params
})
}

View File

@@ -1,13 +1,13 @@
import request from './request' import request from './request'
export function getVapidPublicKey() { export function getVapidPublicKey () {
return request({ return request({
url: '/Notification/GetVapidPublicKey', url: '/Notification/GetVapidPublicKey',
method: 'get' method: 'get'
}) })
} }
export function subscribe(data) { export function subscribe (data) {
return request({ return request({
url: '/Notification/Subscribe', url: '/Notification/Subscribe',
method: 'post', method: 'post',
@@ -15,7 +15,7 @@ export function subscribe(data) {
}) })
} }
export function testNotification(message) { export function testNotification (message) {
return request({ return request({
url: '/Notification/TestNotification', url: '/Notification/TestNotification',
method: 'post', method: 'post',

View File

@@ -1,4 +1,4 @@
import axios from 'axios' import axios from 'axios'
import { showToast } from 'vant' import { showToast } from 'vant'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import router from '@/router' import router from '@/router'
@@ -12,6 +12,15 @@ const request = axios.create({
} }
}) })
// 生成请求ID
const generateRequestId = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
// 请求拦截器 // 请求拦截器
request.interceptors.request.use( request.interceptors.request.use(
(config) => { (config) => {
@@ -20,6 +29,11 @@ request.interceptors.request.use(
if (authStore.token) { if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}` config.headers.Authorization = `Bearer ${authStore.token}`
} }
// 添加请求ID
const requestId = generateRequestId()
config.headers['X-Request-ID'] = requestId
return config return config
}, },
(error) => { (error) => {

View File

@@ -1,4 +1,4 @@
import request from './request' import request from './request'
/** /**
* 统计相关 API * 统计相关 API
@@ -88,15 +88,18 @@ export const getDailyStatistics = (params) => {
} }
/** /**
* 获取指定日期范围内的每日统计 * 获取累积余额统计数据(用于余额卡片)
* @param {Object} params - 查询参数 * @param {Object} params - 查询参数
* @param {string} params.startDate - 开始日期 * @param {number} params.year - 年份
* @param {string} params.endDate - 结束日期 * @param {number} params.month - 月份
* @returns {Promise<{success: boolean, data: Array}>} * @returns {Promise<{success: boolean, data: Array}>}
* @returns {Array} data - 每日累积余额列表
* @returns {string} data[].date - 日期
* @returns {number} data[].cumulativeBalance - 累积余额
*/ */
export const getDailyStatisticsRange = (params) => { export const getBalanceStatistics = (params) => {
return request({ return request({
url: '/TransactionRecord/GetDailyStatisticsRange', url: '/TransactionRecord/GetBalanceStatistics',
method: 'get', method: 'get',
params params
}) })

View File

@@ -76,3 +76,30 @@ export const batchCreateCategories = (dataList) => {
data: dataList data: dataList
}) })
} }
/**
* 为指定分类生成新的SVG图标
* @param {number} categoryId - 分类ID
* @returns {Promise<{success: boolean, data: string}>} 返回生成的SVG内容
*/
export const generateIcon = (categoryId) => {
return request({
url: '/TransactionCategory/GenerateIcon',
method: 'post',
data: { categoryId }
})
}
/**
* 更新分类的选中图标索引
* @param {number} categoryId - 分类ID
* @param {number} selectedIndex - 选中的图标索引
* @returns {Promise<{success: boolean}>}
*/
export const updateSelectedIcon = (categoryId, selectedIndex) => {
return request({
url: '/TransactionCategory/UpdateSelectedIcon',
method: 'post',
data: { categoryId, selectedIndex }
})
}

View File

@@ -1,4 +1,4 @@
import request from './request' import request from './request'
/** /**
* 交易记录相关 API * 交易记录相关 API
@@ -82,6 +82,7 @@ export const createTransaction = (data) => {
* @param {number} data.balance - 交易后余额 * @param {number} data.balance - 交易后余额
* @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支) * @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支)
* @param {string} data.classify - 交易分类 * @param {string} data.classify - 交易分类
* @param {string} [data.occurredAt] - 交易时间
* @returns {Promise<{success: boolean}>} * @returns {Promise<{success: boolean}>}
*/ */
export const updateTransaction = (data) => { export const updateTransaction = (data) => {
@@ -223,32 +224,6 @@ export const nlpAnalysis = (userInput) => {
}) })
} }
/**
* 获取抵账候选列表
* @param {number} id - 当前交易ID
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getCandidatesForOffset = (id) => {
return request({
url: `/TransactionRecord/GetCandidatesForOffset/${id}`,
method: 'get'
})
}
/**
* 抵账(删除两笔交易)
* @param {number} id1 - 交易ID 1
* @param {number} id2 - 交易ID 2
* @returns {Promise<{success: boolean}>}
*/
export const offsetTransactions = (id1, id2) => {
return request({
url: '/TransactionRecord/OffsetTransactions',
method: 'post',
data: { id1, id2 }
})
}
/** /**
* 一句话录账解析 * 一句话录账解析
* @param {string} text - 用户输入的自然语言文本 * @param {string} text - 用户输入的自然语言文本

View File

@@ -31,7 +31,7 @@
--chart-color-10: #aab7b8; --chart-color-10: #aab7b8;
--chart-color-11: #ff8ed4; --chart-color-11: #ff8ed4;
--chart-color-12: #67e6dc; --chart-color-12: #67e6dc;
--chart-color-13: #ffab73; --chart-color-13: #5b8dee;
--chart-color-14: #c9b1ff; --chart-color-14: #c9b1ff;
--chart-color-15: #7bdff2; --chart-color-15: #7bdff2;
@@ -87,7 +87,6 @@ body {
background-color 0.5s; background-color 0.5s;
line-height: 1.6; line-height: 1.6;
font-family: font-family:
Inter,
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
'Segoe UI', 'Segoe UI',

184
Web/src/assets/theme.css Normal file
View File

@@ -0,0 +1,184 @@
/**
* EmailBill 主题系统 - 根据 v2.pen 设计稿
* 用于保持整个应用色彩和布局一致性
*/
:root {
/* ============ 颜色变量 - 浅色主题 ============ */
/* 背景色 */
--bg-primary: #FFFFFF;
--bg-secondary: #F6F7F8;
--bg-tertiary: #F3F4F6;
--bg-button: #F5F5F5;
/* 文字颜色 */
--text-primary: #1A1A1A;
--text-secondary: #6B7280;
--text-tertiary: #9CA3AF;
/* 强调色 */
--accent-primary: #FF6B6B;
--accent-danger: #EF4444;
--accent-warning: #D97706;
--accent-warning-bg: #FFFBEB;
--accent-success: #22C55E;
--accent-success-bg: #F0FDF4;
--accent-info: #6366F1;
--accent-info-bg: #E0E7FF;
/* 图标色 */
--icon-star: #FF6B6B;
--icon-coffee: #FCD34D;
/* ============ 布局变量 ============ */
/* 间距 */
--spacing-xs: 2px;
--spacing-sm: 4px;
--spacing-md: 8px
--spacing-lg: 12px;
--spacing-xl: 16px;
--spacing-2xl: 20px;
--spacing-3xl: 24px;
/* 圆角 */
--radius-sm: 12px;
--radius-md: 16px;
--radius-lg: 20px;
--radius-full: 22px;
/* 字体大小 */
--font-xs: 9px;
--font-sm: 11px;
--font-base: 12px;
--font-md: 13px;
--font-lg: 15px;
--font-xl: 18px;
--font-2xl: 24px;
--font-3xl: 32px;
/* 字体粗细 */
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-extrabold: 800;
/* 字体 */
--font-primary: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Bricolage Grotesque', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* 阴影 (可选) */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.05);
}
/* ============ 深色主题 ============ */
[data-theme="dark"] {
/* 背景色 */
--bg-primary: #09090B;
--bg-secondary: #18181b;
--bg-tertiary: #27272a;
--bg-button: #27272a;
/* 文字颜色 */
--text-primary: #f4f4f5;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
/* 强调色 (深色主题调整) */
--accent-primary: #FF6B6B;
--accent-danger: #f87171;
--accent-warning: #fbbf24;
--accent-warning-bg: #451a03;
--accent-success: #4ade80;
--accent-success-bg: #064e3b;
--accent-info: #818cf8;
--accent-info-bg: #312e81;
/* 图标色 (深色主题) */
--icon-star: #FF6B6B;
--icon-coffee: #FCD34D;
}
/* ============ 通用工具类 ============ */
/* 文字 */
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-tertiary {
color: var(--text-tertiary);
}
.text-danger {
color: var(--accent-danger);
}
/* 背景 */
.bg-primary {
background-color: var(--bg-primary);
}
.bg-secondary {
background-color: var(--bg-secondary);
}
.bg-tertiary {
background-color: var(--bg-tertiary);
}
/* 布局容器 */
.container-fluid {
width: 100%;
max-width: 402px;
margin: 0 auto;
}
/* Flex 布局 */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
/* 间距 */
.gap-xs { gap: var(--spacing-xs); }
.gap-sm { gap: var(--spacing-sm); }
.gap-md { gap: var(--spacing-md); }
.gap-lg { gap: var(--spacing-lg); }
.gap-xl { gap: var(--spacing-xl); }
.gap-2xl { gap: var(--spacing-2xl); }
.gap-3xl { gap: var(--spacing-3xl); }
/* 内边距 */
.p-sm { padding: var(--spacing-md); }
.p-md { padding: var(--spacing-xl); }
.p-lg { padding: var(--spacing-2xl); }
.p-xl { padding: var(--spacing-3xl); }
/* 圆角 */
.rounded-sm { border-radius: var(--radius-sm); }
.rounded-md { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-full { border-radius: var(--radius-full); }

View File

@@ -38,30 +38,58 @@
/> />
<!-- 交易类型 --> <!-- 交易类型 -->
<van-field name="type" label="类型"> <van-field
name="type"
label="类型"
>
<template #input> <template #input>
<van-radio-group v-model="form.type" direction="horizontal" @change="handleTypeChange"> <van-radio-group
<van-radio :name="0"> 支出 </van-radio> v-model="form.type"
<van-radio :name="1"> 收入 </van-radio> direction="horizontal"
<van-radio :name="2"> 不计 </van-radio> @change="handleTypeChange"
>
<van-radio :name="0">
支出
</van-radio>
<van-radio :name="1">
收入
</van-radio>
<van-radio :name="2">
不计
</van-radio>
</van-radio-group> </van-radio-group>
</template> </template>
</van-field> </van-field>
<!-- 分类 --> <!-- 分类 -->
<van-field name="category" label="分类"> <van-field
name="category"
label="分类"
>
<template #input> <template #input>
<span v-if="!categoryName" style="color: var(--van-text-color-3)">请选择分类</span> <span
v-if="!categoryName"
style="color: var(--van-text-color-3)"
>请选择分类</span>
<span v-else>{{ categoryName }}</span> <span v-else>{{ categoryName }}</span>
</template> </template>
</van-field> </van-field>
<!-- 分类选择组件 --> <!-- 分类选择组件 -->
<ClassifySelector v-model="categoryName" :type="form.type" /> <ClassifySelector
v-model="categoryName"
:type="form.type"
/>
</van-cell-group> </van-cell-group>
<div class="actions"> <div class="actions">
<van-button round block type="primary" native-type="submit" :loading="loading"> <van-button
round
block
type="primary"
native-type="submit"
:loading="loading"
>
{{ submitText }} {{ submitText }}
</van-button> </van-button>
<slot name="actions" /> <slot name="actions" />
@@ -69,7 +97,12 @@
</van-form> </van-form>
<!-- 日期选择弹窗 --> <!-- 日期选择弹窗 -->
<van-popup v-model:show="showDatePicker" position="bottom" round teleport="body"> <van-popup
v-model:show="showDatePicker"
position="bottom"
round
teleport="body"
>
<van-date-picker <van-date-picker
v-model="currentDate" v-model="currentDate"
title="选择日期" title="选择日期"
@@ -79,7 +112,12 @@
</van-popup> </van-popup>
<!-- 时间选择弹窗 --> <!-- 时间选择弹窗 -->
<van-popup v-model:show="showTimePicker" position="bottom" round teleport="body"> <van-popup
v-model:show="showTimePicker"
position="bottom"
round
teleport="body"
>
<van-time-picker <van-time-picker
v-model="currentTime" v-model="currentTime"
title="选择时间" title="选择时间"

View File

@@ -1,6 +1,10 @@
<template> <template>
<div class="manual-bill-add"> <div class="manual-bill-add">
<BillForm ref="billFormRef" :loading="saving" @submit="handleSave" /> <BillForm
ref="billFormRef"
:loading="saving"
@submit="handleSave"
/>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,10 @@
<template> <template>
<div> <div>
<div v-if="!parseResult" class="input-section" style="margin: 12px 12px 0 16px"> <div
v-if="!parseResult"
class="input-section"
style="margin: 12px 12px 0 16px"
>
<van-field <van-field
v-model="text" v-model="text"
type="textarea" type="textarea"
@@ -23,7 +27,10 @@
</div> </div>
</div> </div>
<div v-if="parseResult" class="result-section"> <div
v-if="parseResult"
class="result-section"
>
<BillForm <BillForm
:initial-data="parseResult" :initial-data="parseResult"
:loading="saving" :loading="saving"
@@ -31,7 +38,13 @@
@submit="handleSave" @submit="handleSave"
> >
<template #actions> <template #actions>
<van-button plain round block class="mt-2" @click="parseResult = null"> <van-button
plain
round
block
class="mt-2"
@click="parseResult = null"
>
重新输入 重新输入
</van-button> </van-button>
</template> </template>

View File

@@ -1,10 +1,18 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<!-- 普通预算卡片 --> <!-- 普通预算卡片 -->
<div v-if="!budget.noLimit" class="common-card budget-card" @click="toggleExpand"> <div
v-if="!budget.noLimit"
class="common-card budget-card"
:class="{ 'cursor-default': budget.category === 2 }"
@click="toggleExpand"
>
<div class="budget-content-wrapper"> <div class="budget-content-wrapper">
<!-- 折叠状态 --> <!-- 折叠状态 -->
<div v-if="!isExpanded" class="budget-collapsed"> <div
v-if="!isExpanded"
class="budget-collapsed"
>
<div class="collapsed-header"> <div class="collapsed-header">
<div class="budget-info"> <div class="budget-info">
<slot name="tag"> <slot name="tag">
@@ -14,17 +22,26 @@
class="status-tag" class="status-tag"
> >
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }} {{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
<span v-if="budget.isMandatoryExpense" class="mandatory-mark">📌</span> <span
v-if="budget.isMandatoryExpense"
class="mandatory-mark"
>📌</span>
</van-tag> </van-tag>
</slot> </slot>
<h3 class="card-title"> <h3 class="card-title">
{{ budget.name }} {{ budget.name }}
</h3> </h3>
<span v-if="budget.selectedCategories?.length" class="card-subtitle"> <span
v-if="budget.selectedCategories?.length"
class="card-subtitle"
>
({{ budget.selectedCategories.join('、') }}) ({{ budget.selectedCategories.join('、') }})
</span> </span>
</div> </div>
<van-icon name="arrow-down" class="expand-icon" /> <van-icon
name="arrow-down"
class="expand-icon"
/>
</div> </div>
<div class="collapsed-footer"> <div class="collapsed-footer">
@@ -43,14 +60,23 @@
<div class="collapsed-item"> <div class="collapsed-item">
<span class="compact-label">达成率</span> <span class="compact-label">达成率</span>
<span class="compact-value" :class="percentClass">{{ percentage }}%</span> <span
class="compact-value"
:class="percentClass"
>{{ percentage }}%</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 展开状态 --> <!-- 展开状态 -->
<div v-else class="budget-inner-card"> <div
<div class="card-header" style="margin-bottom: 0"> v-else
class="budget-inner-card"
>
<div
class="card-header"
style="margin-bottom: 0"
>
<div class="budget-info"> <div class="budget-info">
<slot name="tag"> <slot name="tag">
<van-tag <van-tag
@@ -59,10 +85,16 @@
class="status-tag" class="status-tag"
> >
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }} {{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
<span v-if="budget.isMandatoryExpense" class="mandatory-mark">📌</span> <span
v-if="budget.isMandatoryExpense"
class="mandatory-mark"
>📌</span>
</van-tag> </van-tag>
</slot> </slot>
<h3 class="card-title" style="max-width: 120px"> <h3
class="card-title"
style="max-width: 120px"
>
{{ budget.name }} {{ budget.name }}
</h3> </h3>
</div> </div>
@@ -84,14 +116,22 @@
@click.stop="handleQueryBills" @click.stop="handleQueryBills"
/> />
<template v-if="budget.category !== 2"> <template v-if="budget.category !== 2">
<van-button icon="edit" size="small" plain @click.stop="$emit('click', budget)" /> <van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template> </template>
</slot> </slot>
</div> </div>
</div> </div>
<div class="budget-body"> <div class="budget-body">
<div v-if="budget.selectedCategories?.length" class="category-tags"> <div
v-if="budget.selectedCategories?.length"
class="category-tags"
>
<van-tag <van-tag
v-for="cat in budget.selectedCategories" v-for="cat in budget.selectedCategories"
:key="cat" :key="cat"
@@ -109,16 +149,17 @@
<div class="progress-section"> <div class="progress-section">
<slot name="progress-info"> <slot name="progress-info">
<span class="period-type" <span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
>{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span
>
<van-progress <van-progress
:percentage="Math.min(percentage, 100)" :percentage="Math.min(percentage, 100)"
stroke-width="8" stroke-width="8"
:color="progressColor" :color="progressColor"
:show-pivot="false" :show-pivot="false"
/> />
<span class="percent" :class="percentClass">{{ percentage }}%</span> <span
class="percent"
:class="percentClass"
>{{ percentage }}%</span>
</slot> </slot>
</div> </div>
<div class="progress-section time-progress"> <div class="progress-section time-progress">
@@ -132,11 +173,25 @@
<span class="percent">{{ timePercentage }}%</span> <span class="percent">{{ timePercentage }}%</span>
</div> </div>
<van-collapse-transition> <transition
<div v-if="budget.description && showDescription" class="budget-description"> name="collapse"
<div class="description-content rich-html-content" v-html="budget.description" /> @enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<div
v-if="budget.description && showDescription"
class="budget-collapse-wrapper"
>
<div class="budget-description">
<div
class="description-content rich-html-content"
v-html="budget.description"
/>
</div>
</div> </div>
</van-collapse-transition> </transition>
</div> </div>
<div class="card-footer"> <div class="card-footer">
@@ -146,7 +201,11 @@
</div> </div>
<!-- 关联账单列表弹窗 --> <!-- 关联账单列表弹窗 -->
<PopupContainer v-model="showBillListModal" title="关联账单列表" height="75%"> <PopupContainer
v-model="showBillListModal"
title="关联账单列表"
height="75%"
>
<TransactionList <TransactionList
:transactions="billList" :transactions="billList"
:loading="billLoading" :loading="billLoading"
@@ -160,25 +219,43 @@
</div> </div>
<!-- 不记额预算卡片 --> <!-- 不记额预算卡片 -->
<div v-else class="common-card budget-card no-limit-card" @click="toggleExpand"> <div
v-else
class="common-card budget-card no-limit-card"
:class="{ 'cursor-default': budget.category === 2 }"
@click="toggleExpand"
>
<div class="budget-content-wrapper"> <div class="budget-content-wrapper">
<!-- 折叠状态 --> <!-- 折叠状态 -->
<div v-if="!isExpanded" class="budget-collapsed"> <div
v-if="!isExpanded"
class="budget-collapsed"
>
<div class="collapsed-header"> <div class="collapsed-header">
<div class="budget-info"> <div class="budget-info">
<slot name="tag"> <slot name="tag">
<van-tag type="success" plain class="status-tag"> <van-tag
type="success"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }} {{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag> </van-tag>
</slot> </slot>
<h3 class="card-title"> <h3 class="card-title">
{{ budget.name }} {{ budget.name }}
</h3> </h3>
<span v-if="budget.selectedCategories?.length" class="card-subtitle"> <span
v-if="budget.selectedCategories?.length"
class="card-subtitle"
>
({{ budget.selectedCategories.join('、') }}) ({{ budget.selectedCategories.join('、') }})
</span> </span>
</div> </div>
<van-icon name="arrow-down" class="expand-icon" /> <van-icon
name="arrow-down"
class="expand-icon"
/>
</div> </div>
<div class="collapsed-footer no-limit-footer"> <div class="collapsed-footer no-limit-footer">
@@ -194,15 +271,28 @@
</div> </div>
<!-- 展开状态 --> <!-- 展开状态 -->
<div v-else class="budget-inner-card"> <div
<div class="card-header" style="margin-bottom: 0"> v-else
class="budget-inner-card"
>
<div
class="card-header"
style="margin-bottom: 0"
>
<div class="budget-info"> <div class="budget-info">
<slot name="tag"> <slot name="tag">
<van-tag type="success" plain class="status-tag"> <van-tag
type="success"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }} {{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag> </van-tag>
</slot> </slot>
<h3 class="card-title" style="max-width: 120px"> <h3
class="card-title"
style="max-width: 120px"
>
{{ budget.name }} {{ budget.name }}
</h3> </h3>
</div> </div>
@@ -224,14 +314,22 @@
@click.stop="handleQueryBills" @click.stop="handleQueryBills"
/> />
<template v-if="budget.category !== 2"> <template v-if="budget.category !== 2">
<van-button icon="edit" size="small" plain @click.stop="$emit('click', budget)" /> <van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template> </template>
</slot> </slot>
</div> </div>
</div> </div>
<div class="budget-body"> <div class="budget-body">
<div v-if="budget.selectedCategories?.length" class="category-tags"> <div
v-if="budget.selectedCategories?.length"
class="category-tags"
>
<van-tag <van-tag
v-for="cat in budget.selectedCategories" v-for="cat in budget.selectedCategories"
:key="cat" :key="cat"
@@ -248,31 +346,53 @@
<div class="amount-item"> <div class="amount-item">
<span> <span>
<span class="label">实际</span> <span class="label">实际</span>
<span class="value" style="margin-left: 12px" <span
>¥{{ budget.current?.toFixed(0) || 0 }}</span class="value"
> style="margin-left: 12px"
>¥{{ budget.current?.toFixed(0) || 0 }}</span>
</span> </span>
</div> </div>
</div> </div>
<div class="no-limit-notice"> <div class="no-limit-notice">
<span> <span>
<van-icon name="info-o" style="margin-right: 4px" /> <van-icon
name="info-o"
style="margin-right: 4px"
/>
不记额预算 - 直接计入存款明细 不记额预算 - 直接计入存款明细
</span> </span>
</div> </div>
<van-collapse-transition> <transition
<div v-if="budget.description && showDescription" class="budget-description"> name="collapse"
<div class="description-content rich-html-content" v-html="budget.description" /> @enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<div
v-if="budget.description && showDescription"
class="budget-collapse-wrapper"
>
<div class="budget-description">
<div
class="description-content rich-html-content"
v-html="budget.description"
/>
</div>
</div> </div>
</van-collapse-transition> </transition>
</div> </div>
</div> </div>
</div> </div>
<!-- 关联账单列表弹窗 --> <!-- 关联账单列表弹窗 -->
<PopupContainer v-model="showBillListModal" title="关联账单列表" height="75%"> <PopupContainer
v-model="showBillListModal"
title="关联账单列表"
height="75%"
>
<TransactionList <TransactionList
:transactions="billList" :transactions="billList"
:loading="billLoading" :loading="billLoading"
@@ -321,6 +441,10 @@ const billList = ref([])
const billLoading = ref(false) const billLoading = ref(false)
const toggleExpand = () => { const toggleExpand = () => {
// 存款类型category === 2强制保持展开状态不可折叠
if (props.budget.category === 2) {
return
}
isExpanded.value = !isExpanded.value isExpanded.value = !isExpanded.value
} }
@@ -387,6 +511,36 @@ const timePercentage = computed(() => {
return Math.round(((now - start) / (end - start)) * 100) return Math.round(((now - start) / (end - start)) * 100)
}) })
const onEnter = (el) => {
el.style.height = '0'
el.style.overflow = 'hidden'
// Force reflow
el.offsetHeight
el.style.transition = 'height 0.3s ease-in-out'
el.style.height = `${el.scrollHeight}px`
}
const onAfterEnter = (el) => {
el.style.height = ''
el.style.overflow = ''
el.style.transition = ''
}
const onLeave = (el) => {
el.style.height = `${el.scrollHeight}px`
el.style.overflow = 'hidden'
// Force reflow
el.offsetHeight
el.style.transition = 'height 0.3s ease-in-out'
el.style.height = '0'
}
const onAfterLeave = (el) => {
el.style.height = ''
el.style.overflow = ''
el.style.transition = ''
}
</script> </script>
<style scoped> <style scoped>
@@ -400,6 +554,10 @@ const timePercentage = computed(() => {
cursor: pointer; cursor: pointer;
} }
.budget-card.cursor-default {
cursor: default;
}
.no-limit-card { .no-limit-card {
border-left: 3px solid var(--van-success-color); border-left: 3px solid var(--van-success-color);
} }
@@ -661,11 +819,13 @@ const timePercentage = computed(() => {
color: var(--van-success-color); color: var(--van-success-color);
} }
.budget-collapse-wrapper {
overflow: hidden;
}
.budget-description { .budget-description {
border-top: 1px solid var(--van-border-color);
margin-top: 8px; margin-top: 8px;
background-color: var(--van-background);
border-radius: 4px;
padding: 8px;
} }
.description-content { .description-content {
@@ -680,15 +840,4 @@ const timePercentage = computed(() => {
display: inline-block; display: inline-block;
} }
/* @media (prefers-color-scheme: dark) {
.budget-description {
background-color: var(--van-background-2);
}
.description-content {
color: var(--van-text-color-2);
}
.collapsed-row .value {
color: var(--van-text-color);
}
} */
</style> </style>

View File

@@ -1,4 +1,4 @@
<template> <template>
<div class="chart-analysis-container"> <div class="chart-analysis-container">
<!-- 仪表盘整体健康度 --> <!-- 仪表盘整体健康度 -->
<div class="gauges-row"> <div class="gauges-row">
@@ -7,15 +7,50 @@
<div class="chart-header"> <div class="chart-header">
<div class="chart-title"> <div class="chart-title">
<!-- 月度健康度 --> <!-- 月度健康度 -->
{{ activeTab === BudgetCategory.Expense ? '使用情况' : '完成情况' }} {{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
(月度) <van-icon
name="info-o"
size="16"
color="var(--van-primary-color)"
style="margin-left: auto; cursor: pointer"
@click="showDescriptionPopup = true; activeDescTab = 'month'"
/>
</div>
</div>
<div class="gauge-wrapper">
<div
ref="monthGaugeRef"
class="chart-body gauge-chart"
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/>
<div class="gauge-text-overlay">
<div
class="remaining-label"
>
{{
activeTab === BudgetCategory.Expense
? (
overallStats.month.current > overallStats.month.limit
? '超支'
: '余额'
)
: overallStats.month.current > overallStats.month.limit
? '超额'
: '差额'
}}
</div>
<div
class="remaining-value"
:style="{ color:
overallStats.month.current > overallStats.month.limit
? activeTab === BudgetCategory.Expense ? 'var(--van-danger-color)' : 'var(--van-success-color)'
: ''
}"
>
¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
</div>
</div> </div>
</div> </div>
<div
ref="monthGaugeRef"
class="chart-body gauge-chart"
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/>
<div class="gauge-footer compact"> <div class="gauge-footer compact">
<div class="gauge-item"> <div class="gauge-item">
<span class="label"> <span class="label">
@@ -36,15 +71,36 @@
<div class="chart-card gauge-card"> <div class="chart-card gauge-card">
<div class="chart-header"> <div class="chart-header">
<div class="chart-title"> <div class="chart-title">
{{ activeTab === BudgetCategory.Expense ? '使用情况' : '完成情况' }} {{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
(年度) <van-icon
name="info-o"
size="16"
color="var(--van-primary-color)"
style="margin-left: auto; cursor: pointer"
@click="showDescriptionPopup = true; activeDescTab = 'year'"
/>
</div>
</div>
<div class="gauge-wrapper">
<div
ref="yearGaugeRef"
class="chart-body gauge-chart"
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/>
<div class="gauge-text-overlay">
<div
class="remaining-label"
>
{{ activeTab === BudgetCategory.Expense ? (overallStats.year.current > overallStats.year.limit ? '超支' : '余额') : '差额' }}
</div>
<div
class="remaining-value"
:style="{ color: activeTab === BudgetCategory.Expense && overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '' }"
>
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
</div>
</div> </div>
</div> </div>
<div
ref="yearGaugeRef"
class="chart-body gauge-chart"
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/>
<div class="gauge-footer compact"> <div class="gauge-footer compact">
<div class="gauge-item"> <div class="gauge-item">
<span class="label"> <span class="label">
@@ -79,26 +135,6 @@
ref="burndownChartRef" ref="burndownChartRef"
class="chart-body burndown-chart" class="chart-body burndown-chart"
/> />
<div class="expand-toggle-row">
<div
class="expand-toggle"
@click="monthVarianceExpanded = !monthVarianceExpanded"
>
<span
class="expand-icon"
:class="{ expanded: monthVarianceExpanded }"
>
</span>
<span class="expand-text">{{ monthVarianceExpanded ? '收起偏差分析' : '展开偏差分析' }}</span>
</div>
</div>
<div
v-if="monthVarianceExpanded"
ref="monthVarianceChartRef"
class="chart-body variance-chart"
:style="{ height: calculateChartHeight(monthBudgets) + 'px' }"
/>
</div> </div>
<!-- 年度预算进度 --> <!-- 年度预算进度 -->
@@ -119,25 +155,26 @@
ref="yearBurndownChartRef" ref="yearBurndownChartRef"
class="chart-body burndown-chart" class="chart-body burndown-chart"
/> />
<div class="expand-toggle-row"> </div>
<div
class="expand-toggle" <!-- 偏差分析 -->
@click="yearVarianceExpanded = !yearVarianceExpanded" <div
> v-if="budgets.length > 0"
<span class="chart-card"
class="expand-icon" style="margin-top: 12px"
:class="{ expanded: yearVarianceExpanded }" >
> <div class="chart-header">
<div class="chart-title">
</span> 偏差分析
<span class="expand-text">{{ yearVarianceExpanded ? '收起偏差分析' : '展开偏差分析' }}</span> </div>
<div class="chart-subtitle">
预算执行偏差排行
</div> </div>
</div> </div>
<div <div
v-if="yearVarianceExpanded" ref="varianceChartRef"
ref="yearVarianceChartRef"
class="chart-body variance-chart" class="chart-body variance-chart"
:style="{ height: calculateChartHeight(yearBudgets) + 'px' }" :style="{ height: calculateChartHeight(budgets) + 'px' }"
/> />
</div> </div>
@@ -152,13 +189,27 @@
/> />
</div> </div>
</div> </div>
<!-- 详细描述弹窗 -->
<PopupContainer
v-model="showDescriptionPopup"
:title="activeDescTab === 'month' ? '预算额度/实际详情(月度)' : '预算额度/实际详情(年度)'"
height="70%"
>
<div
class="rich-html-content"
style="padding: 16px"
v-html="activeDescTab === 'month' ? (overallStats.month?.description || '<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>') : (overallStats.year?.description || '<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>')"
/>
</PopupContainer>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue' import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { BudgetCategory } from '@/constants/enums' import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
import { getCssVar } from '@/utils/theme' import { getCssVar } from '@/utils/theme'
import PopupContainer from '@/components/PopupContainer.vue'
const props = defineProps({ const props = defineProps({
overallStats: { overallStats: {
@@ -172,29 +223,29 @@ const props = defineProps({
activeTab: { activeTab: {
type: [Number, String], type: [Number, String],
default: BudgetCategory.Expense default: BudgetCategory.Expense
},
selectedDate: {
type: Date,
default: () => new Date()
} }
}) })
const monthGaugeRef = ref(null) const monthGaugeRef = ref(null)
const yearGaugeRef = ref(null) const yearGaugeRef = ref(null)
const monthVarianceChartRef = ref(null) const varianceChartRef = ref(null)
const yearVarianceChartRef = ref(null)
const burndownChartRef = ref(null) const burndownChartRef = ref(null)
const yearBurndownChartRef = ref(null) const yearBurndownChartRef = ref(null)
const monthVarianceExpanded = ref(false) // 弹窗状态
const yearVarianceExpanded = ref(false) const showDescriptionPopup = ref(false)
const activeDescTab = ref('month')
let monthGaugeChart = null let monthGaugeChart = null
let yearGaugeChart = null let yearGaugeChart = null
let monthVarianceChart = null let varianceChart = null
let yearVarianceChart = null
let burndownChart = null let burndownChart = null
let yearBurndownChart = null let yearBurndownChart = null
const monthBudgets = computed(() => (props.budgets || []).filter(b => b.type === 1))
const yearBudgets = computed(() => (props.budgets || []).filter(b => b.type === 2))
const formatMoney = (val) => { const formatMoney = (val) => {
if (Math.abs(val) >= 10000) { if (Math.abs(val) >= 10000) {
return (val / 10000).toFixed(1) + 'w' return (val / 10000).toFixed(1) + 'w'
@@ -205,14 +256,6 @@ const formatMoney = (val) => {
}) })
} }
const initGaugeChart = (chartInstance, dom, data, isExpense) => {
if (!dom) { return null }
const chart = echarts.init(dom)
updateSingleGauge(chart, data, isExpense)
return chart
}
const updateSingleGauge = (chart, data, isExpense) => { const updateSingleGauge = (chart, data, isExpense) => {
if (!chart) { return } if (!chart) { return }
@@ -220,20 +263,30 @@ const updateSingleGauge = (chart, data, isExpense) => {
// 展示逻辑:支出显示剩余,收入显示已积累 // 展示逻辑:支出显示剩余,收入显示已积累
let displayRate let displayRate
if (isExpense) { if (isExpense) {
// 支出:显示剩余容量 (100% - 已消耗%),随支出增大逐渐消耗 // 支出:显示剩余容量 (100% - 已消耗%),随支出增大逐渐消耗;超支时显示超出部分
displayRate = Math.max(0, 100 - rate) displayRate = Math.max(0, 100 - rate)
// 如果超支(rate > 100)显示超支部分例如110% -> 显示10%超支)
if (rate > 100) {
displayRate = rate - 100
}
} else { } else {
// 收入:显示已积累 (%),随收入增多逐渐增多 // 收入:显示已积累 (%),随收入增多逐渐增多可以超过100%
displayRate = Math.min(100, rate) displayRate = rate
} }
// 颜色逻辑:支出从绿色消耗到红色,收入从红色积累到绿色 // 颜色逻辑:支出从绿色消耗到红色,收入从红色积累到绿色
let color let color
if (isExpense) { if (isExpense) {
// 支出:满格绿色,随消耗逐渐变红 (根据剩余容量) // 支出:满格绿色,随消耗逐渐变红 (根据剩余容量)
if (displayRate <= 30) { color = getCssVar('--chart-danger') } // 红色 if (rate > 100) {
else if (displayRate <= 65) { color = getCssVar('--chart-warning') } // 橙 color = getCssVar('--chart-danger') // 超支显示红
else { color = getCssVar('--chart-success') } // 绿色 } else if (displayRate <= 30) {
color = getCssVar('--chart-danger') // 红色(剩余很少)
} else if (displayRate <= 65) {
color = getCssVar('--chart-warning') // 橙色
} else {
color = getCssVar('--chart-success') // 绿色(剩余充足)
}
} else { } else {
// 收入:空红色,随积累逐渐变绿 (根据已积累) // 收入:空红色,随积累逐渐变绿 (根据已积累)
if (displayRate <= 30) { color = getCssVar('--chart-danger') } // 红色 if (displayRate <= 30) { color = getCssVar('--chart-danger') } // 红色
@@ -248,10 +301,10 @@ const updateSingleGauge = (chart, data, isExpense) => {
startAngle: 180, startAngle: 180,
endAngle: 0, endAngle: 0,
min: 0, min: 0,
max: 100, max: isExpense && rate > 100 ? 50 : 100, // 超支时显示0-50%范围实际代表0-150%
splitNumber: 5, splitNumber: 5,
radius: '110%', // 放大一点以适应小卡片 radius: '120%', // 放大一点以适应小卡片
center: ['50%', '75%'], center: ['50%', '70%'],
itemStyle: { itemStyle: {
color: color, color: color,
shadowColor: getCssVar('--chart-shadow'), shadowColor: getCssVar('--chart-shadow'),
@@ -279,50 +332,92 @@ const updateSingleGauge = (chart, data, isExpense) => {
valueAnimation: true, valueAnimation: true,
fontSize: 24, // 字体调小 fontSize: 24, // 字体调小
offsetCenter: [0, -5], offsetCenter: [0, -5],
color: 'var(--van-text-color)', color: getCssVar('--van-text-color'),
formatter: '{value}%', formatter: '{value}%',
fontWeight: 'bold', fontWeight: 'bold',
fontFamily: 'DIN Alternate, system-ui' fontFamily: 'DIN Alternate, system-ui',
show: false
}, },
data: [{ value: displayRate }] data: [{ value: displayRate.toFixed(0) }]
} }
] ]
} }
chart.setOption(option) chart.setOption(option, true)
}
const disposeBudgetCharts = () => {
varianceChart?.dispose()
varianceChart = null
burndownChart?.dispose()
burndownChart = null
yearBurndownChart?.dispose()
yearBurndownChart = null
} }
const updateCharts = () => { const updateCharts = () => {
const isExpense = props.activeTab === BudgetCategory.Expense const isExpense = props.activeTab === BudgetCategory.Expense
updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense)
updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense) // 仪表盘总是存在的
if (!monthGaugeChart && monthGaugeRef.value) {
monthGaugeChart = echarts.init(monthGaugeRef.value)
}
if (monthGaugeChart) {
updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense)
}
if (!yearGaugeChart && yearGaugeRef.value) {
yearGaugeChart = echarts.init(yearGaugeRef.value)
}
if (yearGaugeChart) {
updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense)
}
if (props.budgets.length > 0) { if (props.budgets.length > 0) {
// Update Variance Charts // 等待 v-if 相关的 DOM 更新
if (!monthVarianceChart && monthVarianceChartRef.value && monthVarianceExpanded.value) { nextTick(() => {
monthVarianceChart = echarts.init(monthVarianceChartRef.value) // 偏差分析图
} if (varianceChartRef.value) {
if (monthVarianceChart) { const existing = echarts.getInstanceByDom(varianceChartRef.value)
updateVarianceChart(monthVarianceChart, monthBudgets.value) if (existing) {
} varianceChart = existing
} else {
varianceChart?.dispose()
varianceChart = echarts.init(varianceChartRef.value)
}
updateVarianceChart(varianceChart, props.budgets)
varianceChart.resize()
}
if (!yearVarianceChart && yearVarianceChartRef.value && yearVarianceExpanded.value) { // 月度燃尽图
yearVarianceChart = echarts.init(yearVarianceChartRef.value) if (burndownChartRef.value) {
} const existing = echarts.getInstanceByDom(burndownChartRef.value)
if (yearVarianceChart) { if (existing) {
updateVarianceChart(yearVarianceChart, yearBudgets.value) burndownChart = existing
} } else {
burndownChart?.dispose()
burndownChart = echarts.init(burndownChartRef.value)
}
updateBurndownChart()
burndownChart.resize()
}
// 更新燃尽图/积累 // 年度燃尽
if (!burndownChart && burndownChartRef.value) { if (yearBurndownChartRef.value) {
burndownChart = echarts.init(burndownChartRef.value) const existing = echarts.getInstanceByDom(yearBurndownChartRef.value)
} if (existing) {
updateBurndownChart() yearBurndownChart = existing
} else {
if (!yearBurndownChart && yearBurndownChartRef.value) { yearBurndownChart?.dispose()
yearBurndownChart = echarts.init(yearBurndownChartRef.value) yearBurndownChart = echarts.init(yearBurndownChartRef.value)
} }
updateYearBurndownChart() updateYearBurndownChart()
yearBurndownChart.resize()
}
})
} else {
// 预算数据为空DOM 已移除,清理实例
disposeBudgetCharts()
} }
} }
@@ -334,18 +429,26 @@ const updateVarianceChart = (chart, budgets) => {
const current = b.current || 0 const current = b.current || 0
const diff = current - limit const diff = current - limit
return { return {
name: b.name, name: b.name + (b.type === BudgetPeriodType.Year ? ' (年)' : ''),
value: diff, value: diff,
limit: limit, limit: limit,
current: current current: current,
type: b.type
} }
}) })
// Sort by absolute variance // 先月度再年度,各自按偏差绝对值排序
data.sort((a, b) => Math.abs(b.value) - Math.abs(a.value)) const monthlyData = data.filter(item => item.type === BudgetPeriodType.Month)
const annualData = data.filter(item => item.type === BudgetPeriodType.Year)
const categories = data.map(item => item.name) monthlyData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
const values = data.map(item => item.value) annualData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
// 确保月度在前,年度在后
const sortedData = [...annualData,...monthlyData]
const categories = sortedData.map(item => item.name)
const values = sortedData.map(item => item.value)
const maxVal = Math.max(...values.map(v => Math.abs(v))) || 1 const maxVal = Math.max(...values.map(v => Math.abs(v))) || 1
const textColor = getCssVar('--van-text-color') const textColor = getCssVar('--van-text-color')
@@ -362,7 +465,7 @@ const updateVarianceChart = (chart, budgets) => {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
formatter: (params) => { formatter: (params) => {
const item = data[params[0].dataIndex] const item = sortedData[params[0].dataIndex]
let html = `${item.name}<br/>` let html = `${item.name}<br/>`
html += `预算: ¥${formatMoney(item.limit)}<br/>` html += `预算: ¥${formatMoney(item.limit)}<br/>`
html += `实际: ¥${formatMoney(item.current)}<br/>` html += `实际: ¥${formatMoney(item.current)}<br/>`
@@ -449,7 +552,7 @@ const updateVarianceChart = (chart, budgets) => {
] ]
} }
chart.setOption(option) chart.setOption(option, true)
} }
const calculateChartHeight = (budgets) => { const calculateChartHeight = (budgets) => {
@@ -464,12 +567,18 @@ const calculateChartHeight = (budgets) => {
const updateBurndownChart = () => { const updateBurndownChart = () => {
if (!burndownChart) { return } if (!burndownChart) { return }
// 获取当前月份的日期 // 使用传入的所选日期作为参考日期
const today = new Date() const refDate = props.selectedDate
const year = today.getFullYear() const year = refDate.getFullYear()
const month = today.getMonth() const month = refDate.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate() const daysInMonth = new Date(year, month + 1, 0).getDate()
const currentDay = today.getDate()
const now = new Date()
const isCurrentMonth = now.getFullYear() === year && now.getMonth() === month
const isPastMonth =
now.getFullYear() > year || (now.getFullYear() === year && now.getMonth() > month)
// 如果是过去月份,显示完整数据;如果是当前月,显示到今天;如果是将来月,不显示实际数据
const currentDay = isCurrentMonth ? now.getDate() : isPastMonth ? daysInMonth : 0
const isExpense = props.activeTab === BudgetCategory.Expense const isExpense = props.activeTab === BudgetCategory.Expense
// 生成日期和理想燃尽线/积累线 // 生成日期和理想燃尽线/积累线
@@ -487,21 +596,24 @@ const updateBurndownChart = () => {
if (isExpense) { if (isExpense) {
// 支出:燃尽图(向下走) // 支出:燃尽图(向下走)
// 理想燃尽:每天均匀消耗 // 理想燃尽:每天均匀消耗
const idealRemaining = Math.max(0, totalBudget * (1 - i / daysInMonth)) const idealRemaining = totalBudget * (1 - i / daysInMonth)
idealBurndown.push(Math.round(idealRemaining)) idealBurndown.push(Math.round(idealRemaining))
// 实际燃尽:根据当前日期显示 // 实际燃尽:根据当前日期显示,允许负值以表示超支
if (trend.length > 0) { if (trend.length > 0) {
// 后端返回了趋势数据
const dayValue = trend[i - 1] const dayValue = trend[i - 1]
if (dayValue !== undefined && dayValue !== null) { if (dayValue !== undefined && dayValue !== null) {
const actualRemaining = Math.max(0, totalBudget - dayValue) const actualRemaining = totalBudget - dayValue
actualBurndown.push(Math.round(actualRemaining)) actualBurndown.push(Math.round(actualRemaining))
} else { } else {
actualBurndown.push(null) actualBurndown.push(null)
} }
} else { } else {
// 后端没有趋势数据, fallback 到线性估算
if (i <= currentDay && totalBudget > 0) { if (i <= currentDay && totalBudget > 0) {
const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay)) // 允许显示负值以表示超支
const actualRemaining = totalBudget - (currentExpense * i / currentDay)
actualBurndown.push(Math.round(actualRemaining)) actualBurndown.push(Math.round(actualRemaining))
} else { } else {
actualBurndown.push(null) actualBurndown.push(null)
@@ -622,18 +734,22 @@ const updateBurndownChart = () => {
] ]
} }
burndownChart.setOption(option) burndownChart.setOption(option, true)
} }
const updateYearBurndownChart = () => { const updateYearBurndownChart = () => {
if (!yearBurndownChart) { return } if (!yearBurndownChart) { return }
// 获取当前年份的日期 // 使用参考日期
const today = new Date() const refDate = props.selectedDate
const year = today.getFullYear() const year = refDate.getFullYear()
const currentMonth = today.getMonth() const refMonth = refDate.getMonth()
const currentDay = today.getDate()
const daysInCurrentMonth = new Date(year, currentMonth + 1, 0).getDate() const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth()
const currentDay = now.getDate()
const daysInCurrentMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
const isExpense = props.activeTab === BudgetCategory.Expense const isExpense = props.activeTab === BudgetCategory.Expense
// 生成月份和理想燃尽线/积累线 // 生成月份和理想燃尽线/积累线
@@ -656,40 +772,40 @@ const updateYearBurndownChart = () => {
daysInYear += new Date(year, j + 1, 0).getDate() daysInYear += new Date(year, j + 1, 0).getDate()
} }
if (i < currentMonth) { if (year < currentYear || (year === currentYear && i < currentMonth)) {
// 前的月份都已完成 // 前的年/
daysPassedInYear = daysInYear + new Date(year, i + 1, 0).getDate() daysPassedInYear = daysInYear + new Date(year, i + 1, 0).getDate()
} else if (i === currentMonth) { } else if (year === currentYear && i === currentMonth) {
// 当前月 // 当前月
daysPassedInYear = daysInYear + currentDay daysPassedInYear = daysInYear + currentDay
daysInYear += daysInCurrentMonth
} else { } else {
// 未来的月 // 未来的年/
daysInYear += new Date(year, i + 1, 0).getDate() daysPassedInYear = 0
} }
// 全年总天数365或366 const daysInYearTotal = (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)) ? 366 : 365
const daysInYearTotal = new Date(year, 12, 0).getDate() === 29 ? 366 : 365 const yearProgress = daysPassedInYear / daysInYearTotal
const yearProgress = i === 11 ? 1 : daysPassedInYear / daysInYearTotal
if (isExpense) { if (isExpense) {
// 支出:燃尽图(向下走) // 支出:燃尽图(向下走)
// 理想燃尽:每月均匀消耗 // 理想燃尽:每月均匀消耗
const idealRemaining = Math.max(0, totalBudget * (1 - (i + 1) / 12)) const idealRemaining = totalBudget * (1 - (i + 1) / 12)
idealBurndown.push(Math.round(idealRemaining)) idealBurndown.push(Math.round(idealRemaining))
// 实际燃尽:根据当前日期显示 // 实际燃尽:根据日期显示,允许负值以表示超支
if (trend.length > 0) { if (trend.length > 0) {
const monthValue = trend[i] const monthValue = trend[i]
if (monthValue !== undefined && monthValue !== null) { if (monthValue !== undefined && monthValue !== null) {
const actualRemaining = Math.max(0, totalBudget - monthValue) const actualRemaining = totalBudget - monthValue
actualBurndown.push(Math.round(actualRemaining)) actualBurndown.push(Math.round(actualRemaining))
} else { } else {
actualBurndown.push(null) actualBurndown.push(null)
} }
} else { } else {
if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) { // Fallback: 如果是今年且月份未开始,或者去年,做线性统计
const actualRemaining = Math.max(0, totalBudget - (currentExpense * yearProgress)) const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
if (!isFuture && totalBudget > 0) {
const actualRemaining = totalBudget - (currentExpense * yearProgress)
actualBurndown.push(Math.round(actualRemaining)) actualBurndown.push(Math.round(actualRemaining))
} else { } else {
actualBurndown.push(null) actualBurndown.push(null)
@@ -701,7 +817,7 @@ const updateYearBurndownChart = () => {
const idealAccumulated = Math.min(totalBudget, totalBudget * ((i + 1) / 12)) const idealAccumulated = Math.min(totalBudget, totalBudget * ((i + 1) / 12))
idealBurndown.push(Math.round(idealAccumulated)) idealBurndown.push(Math.round(idealAccumulated))
// 实际积累:根据当前日期显示 // 实际积累:根据参数显示
if (trend.length > 0) { if (trend.length > 0) {
const monthValue = trend[i] const monthValue = trend[i]
if (monthValue !== undefined && monthValue !== null) { if (monthValue !== undefined && monthValue !== null) {
@@ -710,7 +826,8 @@ const updateYearBurndownChart = () => {
actualBurndown.push(null) actualBurndown.push(null)
} }
} else { } else {
if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) { const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
if (!isFuture && totalBudget > 0) {
const actualAccumulated = Math.min(totalBudget, currentExpense * yearProgress) const actualAccumulated = Math.min(totalBudget, currentExpense * yearProgress)
actualBurndown.push(Math.round(actualAccumulated)) actualBurndown.push(Math.round(actualAccumulated))
} else { } else {
@@ -808,7 +925,7 @@ const updateYearBurndownChart = () => {
] ]
} }
yearBurndownChart.setOption(option) yearBurndownChart.setOption(option, true)
} }
watch(() => props.overallStats, () => nextTick(updateCharts), { deep: true }) watch(() => props.overallStats, () => nextTick(updateCharts), { deep: true })
@@ -818,6 +935,12 @@ watch(() => props.budgets, () => {
}) })
}, { deep: true }) }, { deep: true })
watch(() => props.selectedDate, () => {
nextTick(() => {
updateCharts()
})
})
watch(() => props.activeTab, () => { watch(() => props.activeTab, () => {
nextTick(() => { nextTick(() => {
updateCharts() updateCharts()
@@ -825,84 +948,22 @@ watch(() => props.activeTab, () => {
setTimeout(() => { setTimeout(() => {
burndownChart?.resize() burndownChart?.resize()
yearBurndownChart?.resize() yearBurndownChart?.resize()
monthVarianceChart?.resize() varianceChart?.resize()
yearVarianceChart?.resize()
}, 100) }, 100)
}) })
}) })
// 监听展开/折叠状态
watch(() => monthVarianceExpanded.value, (expanded) => {
if (expanded) {
nextTick(() => {
if (monthVarianceChartRef.value) {
if (!monthVarianceChart) {
monthVarianceChart = echarts.init(monthVarianceChartRef.value)
}
updateVarianceChart(monthVarianceChart, monthBudgets.value)
monthVarianceChart.resize()
}
})
} else {
// 收起时销毁图表实例,释放内存
if (monthVarianceChart) {
monthVarianceChart.dispose()
monthVarianceChart = null
}
}
})
watch(() => yearVarianceExpanded.value, (expanded) => {
if (expanded) {
nextTick(() => {
if (yearVarianceChartRef.value) {
if (!yearVarianceChart) {
yearVarianceChart = echarts.init(yearVarianceChartRef.value)
}
updateVarianceChart(yearVarianceChart, yearBudgets.value)
yearVarianceChart.resize()
}
})
} else {
// 收起时销毁图表实例,释放内存
if (yearVarianceChart) {
yearVarianceChart.dispose()
yearVarianceChart = null
}
}
})
const handleResize = () => { const handleResize = () => {
monthGaugeChart?.resize() monthGaugeChart?.resize()
yearGaugeChart?.resize() yearGaugeChart?.resize()
monthVarianceChart?.resize() varianceChart?.resize()
yearVarianceChart?.resize()
burndownChart?.resize() burndownChart?.resize()
yearBurndownChart?.resize() yearBurndownChart?.resize()
} }
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
const isExpense = props.activeTab === BudgetCategory.Expense updateCharts()
monthGaugeChart = initGaugeChart(monthGaugeChart, monthGaugeRef.value, props.overallStats.month, isExpense)
yearGaugeChart = initGaugeChart(yearGaugeChart, yearGaugeRef.value, props.overallStats.year, isExpense)
// 只在有数据时初始化柱状图
if (props.budgets.length > 0) {
// Variance charts are initialized when expanded, or if we want to init them eagerly?
// Based on watch logic, we init when expanded.
// But updateCharts logic tries to update if exists.
// 初始化燃尽图/积累图
if (burndownChartRef.value) {
burndownChart = echarts.init(burndownChartRef.value)
updateBurndownChart()
}
if (yearBurndownChartRef.value) {
yearBurndownChart = echarts.init(yearBurndownChartRef.value)
updateYearBurndownChart()
}
}
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
}) })
}) })
@@ -910,11 +971,10 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
monthGaugeChart?.dispose() monthGaugeChart?.dispose()
monthGaugeChart = null
yearGaugeChart?.dispose() yearGaugeChart?.dispose()
monthVarianceChart?.dispose() yearGaugeChart = null
yearVarianceChart?.dispose() disposeBudgetCharts()
burndownChart?.dispose()
yearBurndownChart?.dispose()
}) })
</script> </script>
@@ -945,6 +1005,42 @@ onUnmounted(() => {
/* 减小内边距 */ /* 减小内边距 */
} }
.gauge-wrapper {
position: relative;
width: 100%;
height: 120px;
}
.gauge-text-overlay {
position: absolute;
bottom: 20%;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
}
.remaining-value {
font-size: 24px;
font-weight: bold;
font-family: DIN Alternate, system-ui;
color: var(--van-text-color);
line-height: 1;
transform-origin: center;
}
.remaining-label {
font-size: 10px;
color: var(--van-text-color-2);
margin-top: 4px;
font-family: system-ui;
transform-origin: center;
}
.chart-header { .chart-header {
margin-bottom: 12px; margin-bottom: 12px;
} }
@@ -979,7 +1075,7 @@ onUnmounted(() => {
} }
.burndown-chart { .burndown-chart {
height: 230px; height: 190px;
} }
.gauge-footer { .gauge-footer {
@@ -1016,51 +1112,5 @@ onUnmounted(() => {
color: var(--van-primary-color); color: var(--van-primary-color);
} }
.expand-toggle-row { /* expand styles removed as they are no longer used */
display: flex; </style>
justify-content: center;
align-items: center;
padding: 0;
}
.expand-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 12px;
border-radius: 6px;
transition: background-color 0.2s ease;
user-select: none;
}
.expand-toggle:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.expand-toggle:active {
background-color: rgba(0, 0, 0, 0.1);
}
.expand-icon {
display: inline-block;
font-size: 12px;
color: var(--van-text-color-2);
transition: transform 0.2s ease;
transform-origin: center;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
.expand-text {
font-size: 12px;
color: var(--van-text-color-2);
transition: color 0.2s ease;
}
.expand-toggle:hover .expand-text {
color: var(--van-text-color);
}
</style>

View File

@@ -1,21 +1,35 @@
<template> <template>
<div class="summary-container"> <div class="summary-container">
<transition :name="transitionName" mode="out-in"> <transition
:name="transitionName"
mode="out-in"
>
<div <div
v-if="stats && (stats.month || stats.year)" v-if="stats && (stats.month || stats.year)"
:key="dateKey" :key="dateKey"
class="summary-card common-card" class="summary-card common-card"
> >
<!-- 左切换按钮 --> <!-- 左切换按钮 -->
<div class="nav-arrow left" @click.stop="changeMonth(-1)"> <div
class="nav-arrow left"
@click.stop="changeMonth(-1)"
>
<van-icon name="arrow-left" /> <van-icon name="arrow-left" />
</div> </div>
<div class="summary-content"> <div class="summary-content">
<template v-for="(config, key) in periodConfigs" :key="key"> <template
v-for="(config, key) in periodConfigs"
:key="key"
>
<div class="summary-item"> <div class="summary-item">
<div class="label">{{ config.label }}{{ title }}</div> <div class="label">
<div class="value" :class="getValueClass(stats[key]?.rate || '0.0')"> {{ config.label }}{{ title }}
</div>
<div
class="value"
:class="getValueClass(stats[key]?.rate || '0.0')"
>
{{ stats[key]?.rate || '0.0' }}<span class="unit">%</span> {{ stats[key]?.rate || '0.0' }}<span class="unit">%</span>
</div> </div>
<div class="sub-info"> <div class="sub-info">
@@ -24,7 +38,10 @@
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span> <span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
</div> </div>
</div> </div>
<div v-if="config.showDivider" class="divider" /> <div
v-if="config.showDivider"
class="divider"
/>
</template> </template>
</div> </div>
@@ -38,7 +55,10 @@
</div> </div>
<!-- 非本月时显示的日期标识 --> <!-- 非本月时显示的日期标识 -->
<div v-if="!isCurrentMonth" class="date-tag"> <div
v-if="!isCurrentMonth"
class="date-tag"
>
{{ props.date.getFullYear() }}{{ props.date.getMonth() + 1 }} {{ props.date.getFullYear() }}{{ props.date.getMonth() + 1 }}
</div> </div>
</div> </div>

View File

@@ -1,12 +1,20 @@
<template> <template>
<PopupContainer v-model="visible" title="设置存款分类" height="60%"> <PopupContainer
v-model="visible"
title="设置存款分类"
height="60%"
>
<div class="savings-config-content"> <div class="savings-config-content">
<div class="config-header"> <div class="config-header">
<p class="subtitle">这些分类的统计值将计入存款</p> <p class="subtitle">
这些分类的统计值将计入存款
</p>
</div> </div>
<div class="category-section"> <div class="category-section">
<div class="section-title">可多选分类</div> <div class="section-title">
可多选分类
</div>
<ClassifySelector <ClassifySelector
v-model="selectedCategories" v-model="selectedCategories"
:type="2" :type="2"
@@ -18,7 +26,14 @@
</div> </div>
<template #footer> <template #footer>
<van-button block round type="primary" @click="onSubmit"> 保存配置 </van-button> <van-button
block
round
type="primary"
@click="onSubmit"
>
保存配置
</van-button>
</template> </template>
</PopupContainer> </PopupContainer>
</template> </template>

View File

@@ -1,446 +0,0 @@
<template>
<div class="heatmap-card">
<div class="grid-row">
<!-- Weekday Labels (Fixed Left) -->
<div class="weekday-col-fixed">
<div class="weekday-label">
</div>
<div class="weekday-label">
</div>
<div class="weekday-label">
</div>
</div>
<!-- Scrollable Heatmap Area -->
<div
ref="scrollContainer"
class="heatmap-scroll-container"
>
<div class="heatmap-content">
<!-- Month Labels -->
<div class="month-row">
<div
v-for="(month, index) in monthLabels"
:key="index"
class="month-label"
:style="{ left: month.left + 'px' }"
>
{{ month.text }}
</div>
</div>
<!-- Heatmap Grid -->
<div class="heatmap-grid">
<div
v-for="(week, wIndex) in weeks"
:key="wIndex"
class="heatmap-week"
>
<div
v-for="(day, dIndex) in week"
:key="dIndex"
class="heatmap-cell"
:class="getLevelClass(day)"
@click="onCellClick(day)"
>
<!-- Tooltip could be implemented here or using title -->
</div>
</div>
</div>
</div>
</div>
</div>
<div class="heatmap-footer">
<div
v-if="totalCount > 0"
class="summary-text"
>
过去一年共 {{ totalCount }} 笔交易
</div>
<div class="legend">
<span></span>
<div class="legend-item level-0" />
<div class="legend-item level-1" />
<div class="legend-item level-2" />
<div class="legend-item level-3" />
<div class="legend-item level-4" />
<span></span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { getDailyStatisticsRange } from '@/api/statistics'
const stats = ref({})
const weeks = ref([])
const monthLabels = ref([])
const totalCount = ref(0)
const scrollContainer = ref(null)
const thresholds = ref([2, 4, 7]) // Default thresholds
const CELL_SIZE = 15
const CELL_GAP = 3
const WEEK_WIDTH = CELL_SIZE + CELL_GAP
const formatDate = (d) => {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const fetchData = async () => {
const endDate = new Date()
const startDate = new Date()
startDate.setFullYear(endDate.getFullYear() - 1)
try {
const res = await getDailyStatisticsRange({
startDate: formatDate(startDate),
endDate: formatDate(endDate)
})
if (res.success) {
const map = {}
let count = 0
res.data.forEach((item) => {
map[item.date] = item
count += item.count
})
stats.value = map
totalCount.value = count
// Calculate thresholds based on last 15 days average
const today = new Date()
let last15DaysSum = 0
for (let i = 0; i < 15; i++) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const dateStr = formatDate(d)
last15DaysSum += map[dateStr]?.count || 0
}
const avg = last15DaysSum / 15
// Step size calculation: ensure at least 1, roughly avg/2 to create spread
// Level 1: 1 ~ step
// Level 2: step+1 ~ step*2
// Level 3: step*2+1 ~ step*3
// Level 4: > step*3
const step = Math.max(Math.ceil(avg / 2), 1)
thresholds.value = [step, step * 2, step * 3]
generateHeatmapData(startDate, endDate)
}
} catch (e) {
console.error('Failed to fetch heatmap data', e)
}
}
const generateHeatmapData = (startDate, endDate) => {
const current = new Date(startDate)
const allDays = []
// Adjust start date to be Monday to align weeks
// 0 = Sunday, 1 = Monday
const startDay = current.getDay()
// If startDay is 0 (Sunday), we need to go back 6 days to Monday
// If startDay is 1 (Monday), we are good
// If startDay is 2 (Tuesday), we need to go back 1 day
// Formula: (day + 6) % 7 days back?
// Monday (1) -> 0 days back
// Sunday (0) -> 6 days back
// Tuesday (2) -> 1 day back
// We don't necessarily need to subtract from startDate for data fetching,
// but for grid alignment we want the first column to start on Monday.
const alignStart = new Date(startDate)
// alignStart.setDate(alignStart.getDate() - daysToSubtract);
const tempDate = new Date(alignStart)
while (tempDate <= endDate) {
const dateStr = formatDate(tempDate)
allDays.push({
date: dateStr,
count: stats.value[dateStr]?.count || 0,
obj: new Date(tempDate)
})
tempDate.setDate(tempDate.getDate() + 1)
}
// Now group into weeks
const resultWeeks = []
let currentWeek = []
// Pad first week if start date is not Monday
// allDays[0] is startDate
const firstDayObj = new Date(allDays[0].date)
const firstDay = firstDayObj.getDay() // 0-6 (Sun-Sat)
// We want Monday (1) to be index 0
// Mon(1)->0, Tue(2)->1, ..., Sun(0)->6
const padCount = (firstDay + 6) % 7
for (let i = 0; i < padCount; i++) {
currentWeek.push(null)
}
allDays.forEach((day) => {
currentWeek.push(day)
if (currentWeek.length === 7) {
resultWeeks.push(currentWeek)
currentWeek = []
}
})
// Push last partial week
if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push(null)
}
resultWeeks.push(currentWeek)
}
weeks.value = resultWeeks
// Generate Month Labels
const labels = []
let lastMonth = -1
resultWeeks.forEach((week, index) => {
// Check the first valid day in the week
const day = week.find((d) => d !== null)
if (day) {
const d = new Date(day.date)
const month = d.getMonth()
if (month !== lastMonth) {
labels.push({
text: d.toLocaleString('zh-CN', { month: 'short' }),
left: index * WEEK_WIDTH
})
lastMonth = month
}
}
})
monthLabels.value = labels
// Scroll to end
nextTick(() => {
if (scrollContainer.value) {
scrollContainer.value.scrollLeft = scrollContainer.value.scrollWidth
}
})
}
const getLevelClass = (day) => {
if (!day) {
return 'invisible'
}
const count = day.count
if (count === 0) {
return 'level-0'
}
if (count <= thresholds.value[0]) {
return 'level-1'
}
if (count <= thresholds.value[1]) {
return 'level-2'
}
if (count <= thresholds.value[2]) {
return 'level-3'
}
return 'level-4'
}
const onCellClick = (day) => {
if (day) {
// Emit event or show toast
// console.log(day);
}
}
defineExpose({
refresh: fetchData
})
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.heatmap-card {
background: var(--van-background-2);
border-radius: 8px;
padding: 12px;
color: var(--van-text-color);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
margin: 0 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid var(--van-border-color);
}
.heatmap-scroll-container {
overflow-x: auto;
padding-bottom: 8px;
scrollbar-width: none;
flex: 1; /* Take remaining space */
}
.heatmap-scroll-container::-webkit-scrollbar {
display: none;
}
.heatmap-content {
display: inline-block;
min-width: 100%;
}
.month-row {
display: flex;
margin-bottom: 4px;
height: 15px;
position: relative;
}
.months-container {
position: relative;
flex-grow: 1;
height: 100%;
}
.month-label {
position: absolute;
font-size: 10px;
top: 0;
color: var(--van-text-color-2);
white-space: nowrap;
}
.grid-row {
display: flex;
position: relative;
}
.weekday-col-fixed {
display: flex;
flex-direction: column;
padding-top: 19px; /* Align with cells (month row height 15px + margin 4px) */
margin-right: 6px;
font-size: 9px;
height: 142px; /* Total height: 15 (month) + 4 (margin) + 123 (grid) */
color: var(--van-text-color-2);
flex-shrink: 0;
z-index: 10;
background-color: var(--van-background-2); /* Match card background */
}
.weekday-label {
height: 15px;
line-height: 15px;
margin-top: 15px; /* (15 cell + 3 gap)*2 - 15 height - previous margin? No. */
/*
Row 0: 0px top
Row 1: 18px top (15+3) - Label "二" aligns here? No, "二" is usually row 1 (index 1, 2nd row)
If we want to align with 2nd, 4th, 6th rows (indices 1, 3, 5):
Row 0: y=0
Row 1: y=18
Row 2: y=36
Row 3: y=54
Row 4: y=72
Row 5: y=90
Row 6: y=108
Label 1 ("二") at Row 1 (y=18)
Label 2 ("四") at Row 3 (y=54)
Label 3 ("六") at Row 5 (y=90)
Padding-top of container is 19px.
First label margin-top: 18px
Second label margin-top: (54 - (18+15)) = 21px
Third label margin-top: (90 - (54+15)) = 21px
Let's try standard spacing.
Gap between tops is 36px (2 rows).
Height of label is 15px.
Margin needed is 36 - 15 = 21px.
First label top needs to be at 18px relative to grid start.
Container padding-top aligns with grid start (row 0 top).
So first label margin-top should be 18px.
*/
margin-top: 21px;
}
.weekday-label:first-child {
margin-top: 18px;
}
.heatmap-grid {
display: flex;
gap: 3px;
}
.heatmap-week {
display: flex;
flex-direction: column;
gap: 3px;
}
.heatmap-cell {
width: 15px;
height: 15px;
border-radius: 2px;
background-color: var(--van-gray-2);
box-sizing: border-box;
}
.heatmap-cell.invisible {
background-color: transparent;
}
.level-0 {
background-color: var(--heatmap-level-0);
}
.level-1 {
background-color: var(--heatmap-level-1);
}
.level-2 {
background-color: var(--heatmap-level-2);
}
.level-3 {
background-color: var(--heatmap-level-3);
}
.level-4 {
background-color: var(--heatmap-level-4);
}
.heatmap-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 10px;
color: var(--van-text-color-2);
}
.legend {
display: flex;
align-items: center;
gap: 3px;
}
.legend-item {
width: 15px;
height: 15px;
border-radius: 2px;
}
</style>

View File

@@ -1,18 +1,40 @@
<template> <template>
<div class="global-add-bill"> <div class="global-add-bill">
<!-- Floating Add Bill Button --> <!-- Floating Add Bill Button -->
<div class="floating-add" @click="openAddBill"> <div
class="floating-add"
@click="openAddBill"
>
<van-icon name="plus" /> <van-icon name="plus" />
</div> </div>
<!-- Add Bill Modal --> <!-- Add Bill Modal -->
<PopupContainer v-model="showAddBill" title="记一笔" height="75%"> <PopupContainer
<van-tabs v-model:active="activeTab" shrink> v-model="showAddBill"
<van-tab title="一句话录账" name="one"> title="记一笔"
<OneLineBillAdd :key="componentKey" @success="handleSuccess" /> height="75%"
>
<van-tabs
v-model:active="activeTab"
shrink
>
<van-tab
title="一句话录账"
name="one"
>
<OneLineBillAdd
:key="componentKey"
@success="handleSuccess"
/>
</van-tab> </van-tab>
<van-tab title="手动录账" name="manual"> <van-tab
<ManualBillAdd :key="componentKey" @success="handleSuccess" /> title="手动录账"
name="manual"
>
<ManualBillAdd
:key="componentKey"
@success="handleSuccess"
/>
</van-tab> </van-tab>
</van-tabs> </van-tabs>
</PopupContainer> </PopupContainer>

View File

@@ -12,21 +12,36 @@
<!-- 头部区域 --> <!-- 头部区域 -->
<div class="popup-header-fixed"> <div class="popup-header-fixed">
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) --> <!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
<div class="header-title-row" :class="{ 'has-actions': !subtitle && hasActions }"> <div
class="header-title-row"
:class="{ 'has-actions': !subtitle && hasActions }"
>
<h3 class="popup-title"> <h3 class="popup-title">
{{ title }} {{ title }}
</h3> </h3>
<!-- 无子标题时操作按钮与标题同行 --> <!-- 无子标题时操作按钮与标题同行 -->
<div v-if="!subtitle && hasActions" class="header-actions-inline"> <div
v-if="!subtitle && hasActions"
class="header-actions-inline"
>
<slot name="header-actions" /> <slot name="header-actions" />
</div> </div>
</div> </div>
<!-- 子标题/统计信息 --> <!-- 子标题/统计信息 -->
<div v-if="subtitle" class="header-stats"> <div
<span class="stats-text" v-html="subtitle" /> v-if="subtitle"
class="header-stats"
>
<span
class="stats-text"
v-html="subtitle"
/>
<!-- 额外操作插槽 --> <!-- 额外操作插槽 -->
<slot v-if="hasActions" name="header-actions" /> <slot
v-if="hasActions"
name="header-actions"
/>
</div> </div>
</div> </div>
@@ -36,7 +51,10 @@
</div> </div>
<!-- 底部页脚固定不可滚动 --> <!-- 底部页脚固定不可滚动 -->
<div v-if="slots.footer" class="popup-footer-fixed"> <div
v-if="slots.footer"
class="popup-footer-fixed"
>
<slot name="footer" /> <slot name="footer" />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,681 @@
<template>
<van-popup
v-model:show="visible"
position="bottom"
:style="{ height: 'auto', maxHeight: '85%', borderTopLeftRadius: '16px', borderTopRightRadius: '16px' }"
teleport="body"
@close="handleClose"
>
<div class="transaction-detail-sheet">
<!-- 头部 -->
<div class="sheet-header">
<div class="header-title">
交易详情
</div>
<van-icon
name="cross"
class="header-close"
@click="handleClose"
/>
</div>
<!-- 金额区域 -->
<div class="amount-section">
<div class="amount-label">
金额
</div>
<!-- 只读显示模式 -->
<div
v-if="!isEditingAmount"
class="amount-value"
@click="startEditAmount"
>
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
</div>
<!-- 编辑模式 -->
<div v-else class="amount-input-wrapper">
<span class="currency-symbol">¥</span>
<input
ref="amountInputRef"
v-model="editForm.amount"
type="number"
inputmode="decimal"
class="amount-input"
placeholder="0.00"
step="0.01"
min="0"
@blur="finishEditAmount"
>
</div>
</div>
<!-- 表单字段 -->
<div class="form-section">
<div class="form-row">
<div class="form-label">
时间
</div>
<div
class="form-value clickable"
@click="showDatePicker = true"
>
{{ formatDateTime(editForm.occurredAt) }}
</div>
</div>
<div class="form-row no-border">
<div class="form-label">
备注
</div>
<div class="form-value">
<input
v-model="editForm.reason"
type="text"
class="reason-input"
placeholder="请输入备注"
>
</div>
</div>
<div class="form-row">
<div class="form-label">
类型
</div>
<div class="form-value">
<van-radio-group
v-model="editForm.type"
direction="horizontal"
@change="handleTypeChange"
>
<van-radio
:name="0"
class="type-radio"
>
支出
</van-radio>
<van-radio
:name="1"
class="type-radio"
>
收入
</van-radio>
<van-radio
:name="2"
class="type-radio"
>
不计
</van-radio>
</van-radio-group>
</div>
</div>
<div class="form-row">
<div class="form-label">
分类
</div>
<div
class="form-value clickable"
@click="showClassifySelector = !showClassifySelector"
>
<span v-if="editForm.classify">{{ editForm.classify }}</span>
<span
v-else
class="placeholder"
>请选择分类</span>
</div>
</div>
</div>
<!-- 分类选择器展开/收起 -->
<div
v-if="showClassifySelector"
class="classify-section"
>
<ClassifySelector
v-model="editForm.classify"
:type="editForm.type"
:show-add="false"
:show-clear="false"
:show-all="false"
@change="handleClassifyChange"
/>
</div>
<!-- 操作按钮 -->
<div class="actions-section">
<van-button
class="delete-btn"
:loading="deleting"
@click="handleDelete"
>
删除
</van-button>
<van-button
class="save-btn"
type="primary"
:loading="saving"
@click="handleSave"
>
保存
</van-button>
</div>
</div>
<!-- 日期时间选择器 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
>
<van-datetime-picker
v-model="currentDateTime"
type="datetime"
title="选择日期时间"
:min-date="minDate"
:max-date="maxDate"
@confirm="handleDateTimeConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
</van-popup>
</template>
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { showToast, showDialog } from 'vant'
import dayjs from 'dayjs'
import ClassifySelector from '@/components/ClassifySelector.vue'
import {
updateTransaction,
deleteTransaction
} from '@/api/transactionRecord'
const props = defineProps({
show: {
type: Boolean,
default: false
},
transaction: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:show', 'save', 'delete'])
const visible = ref(false)
const saving = ref(false)
const deleting = ref(false)
const showDatePicker = ref(false)
const showClassifySelector = ref(false)
const isEditingAmount = ref(false)
// 金额输入框引用
const amountInputRef = ref(null)
// 日期时间选择器配置
const minDate = new Date(2020, 0, 1)
const maxDate = new Date(2030, 11, 31)
const currentDateTime = ref(new Date())
// 编辑表单
const editForm = reactive({
id: 0,
amount: 0,
type: 0,
classify: '',
occurredAt: '',
reason: ''
})
// 监听 props 变化
watch(
() => props.show,
(newVal) => {
visible.value = newVal
}
)
watch(
() => props.transaction,
(newVal) => {
if (newVal) {
// 填充表单数据
editForm.id = newVal.id
editForm.amount = newVal.amount
editForm.type = newVal.type
editForm.classify = newVal.classify || ''
editForm.occurredAt = newVal.occurredAt
editForm.reason = newVal.reason || ''
// 初始化日期时间
if (newVal.occurredAt) {
currentDateTime.value = new Date(newVal.occurredAt)
}
// 收起分类选择器
showClassifySelector.value = false
}
}
)
watch(visible, (newVal) => {
emit('update:show', newVal)
if (!newVal) {
// 关闭时收起分类选择器
showClassifySelector.value = false
}
})
// 格式化金额显示
const formatAmount = (amount) => {
return Number(amount).toFixed(2)
}
// 开始编辑金额
const startEditAmount = () => {
isEditingAmount.value = true
// 自动聚焦输入框
setTimeout(() => {
amountInputRef.value?.focus()
// 选中所有文本,方便用户直接输入新值
amountInputRef.value?.select()
}, 100)
}
// 完成编辑金额
const finishEditAmount = () => {
// 验证并格式化金额
const parsed = parseFloat(editForm.amount)
editForm.amount = isNaN(parsed) || parsed < 0 ? 0 : parsed
isEditingAmount.value = false
}
// 格式化日期时间显示
const formatDateTime = (dateTime) => {
if (!dateTime) {return ''}
return dayjs(dateTime).format('YYYY-MM-DD HH:mm')
}
// 类型切换
const handleTypeChange = () => {
// 切换类型时清空分类,让用户重新选择
editForm.classify = ''
showClassifySelector.value = false
}
// 分类选择变化 - 自动保存
const handleClassifyChange = async () => {
if (editForm.id > 0 && editForm.classify) {
await handleSave()
}
}
// 日期时间确认
const handleDateTimeConfirm = (value) => {
editForm.occurredAt = dayjs(value).format('YYYY-MM-DDTHH:mm:ss')
currentDateTime.value = value
showDatePicker.value = false
}
// 保存修改
const handleSave = async () => {
try {
// 验证必填字段
if (!editForm.amount || editForm.amount <= 0) {
showToast('请输入有效金额')
return
}
if (!editForm.classify) {
showToast('请选择分类')
return
}
if (!editForm.occurredAt) {
showToast('请选择交易时间')
return
}
saving.value = true
const data = {
id: editForm.id,
amount: editForm.amount,
type: editForm.type,
classify: editForm.classify,
occurredAt: editForm.occurredAt,
reason: editForm.reason
}
const response = await updateTransaction(data)
if (response.success) {
showToast('保存成功')
emit('save', data)
visible.value = false
} else {
showToast(response.message || '保存失败')
}
} catch (error) {
console.error('保存出错:', error)
showToast('保存失败')
} finally {
saving.value = false
}
}
// 删除交易
const handleDelete = async () => {
showDialog({
title: '确认删除',
message: '确定要删除这条交易记录吗?删除后无法恢复。',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonColor: '#EF4444'
}).then(async () => {
try {
deleting.value = true
const response = await deleteTransaction(editForm.id)
if (response.success) {
showToast('删除成功')
emit('delete', editForm.id)
visible.value = false
} else {
showToast(response.message || '删除失败')
}
} catch (error) {
console.error('删除出错:', error)
showToast('删除失败')
} finally {
deleting.value = false
}
}).catch(() => {
// 用户取消删除
})
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
</script>
<style scoped lang="scss">
.transaction-detail-sheet {
background: #FFFFFF;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
.sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
color: #09090B;
}
.header-close {
font-size: 24px;
color: #71717A;
cursor: pointer;
}
}
.amount-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 0;
.amount-label {
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: normal;
color: #71717A;
}
.amount-value {
font-family: Inter, sans-serif;
font-size: 32px;
font-weight: 700;
color: #09090B;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
}
.amount-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
.currency-symbol {
font-size: 32px;
font-weight: 700;
color: #09090B;
}
.amount-input {
max-width: 200px;
font-size: 32px;
font-weight: 700;
color: #09090B;
border: none;
outline: none;
background: transparent;
text-align: center;
padding: 8px 0;
border-bottom: 2px solid #E4E4E7;
transition: border-color 0.3s;
&:focus {
border-bottom-color: #6366F1;
}
&::placeholder {
color: #A1A1AA;
}
// 移除 number 类型的上下箭头
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
// Firefox
&[type='number'] {
-moz-appearance: textfield;
}
}
}
}
.form-section {
display: flex;
flex-direction: column;
gap: 16px;
.form-row {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
border-bottom: 1px solid #E4E4E7;
&.no-border {
border-bottom: none;
}
.form-label {
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: normal;
color: #71717A;
}
.form-value {
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: normal;
color: #09090B;
text-align: right;
flex: 1;
margin-left: 16px;
&.clickable {
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
}
.placeholder {
color: #A1A1AA;
}
.reason-input {
width: 100%;
border: none;
outline: none;
text-align: right;
font-family: Inter, sans-serif;
font-size: 16px;
color: #09090B;
background: transparent;
&::placeholder {
color: #A1A1AA;
}
}
:deep(.van-radio-group) {
display: flex;
gap: 16px;
justify-content: flex-end;
}
:deep(.van-radio) {
margin: 0;
}
:deep(.van-radio__label) {
margin-left: 4px;
}
}
}
}
.classify-section {
padding: 16px;
background: #F4F4F5;
border-radius: 8px;
margin-top: -8px;
}
.actions-section {
display: flex;
gap: 16px;
width: 100%;
.delete-btn {
flex: 1;
height: 48px;
border-radius: 8px;
border: 1px solid #EF4444;
background: transparent;
color: #EF4444;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
.save-btn {
flex: 1;
height: 48px;
border-radius: 8px;
background: #6366F1;
color: #FAFAFA;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
}
}
// 暗色模式
@media (prefers-color-scheme: dark) {
.transaction-detail-sheet {
background: #18181B;
.sheet-header {
.header-title {
color: #FAFAFA;
}
.header-close {
color: #A1A1AA;
}
}
.amount-section {
.amount-label {
color: #A1A1AA;
}
.amount-value {
color: #FAFAFA;
}
.amount-input-wrapper {
.currency-symbol {
color: #FAFAFA;
}
.amount-input {
color: #FAFAFA;
border-bottom-color: #27272A;
&:focus {
border-bottom-color: #6366F1;
}
}
}
}
.form-section {
.form-row {
border-bottom-color: #27272A;
.form-label {
color: #A1A1AA;
}
.form-value {
color: #FAFAFA;
.reason-input {
color: #FAFAFA;
}
}
}
}
.classify-section {
background: #27272A;
}
}
}
</style>

View File

@@ -1,15 +1,22 @@
<template> <template>
<PopupContainer v-model="visible" title="交易详情" height="75%" :closeable="false"> <PopupContainer
<template #header-actions> v-model="visible"
<van-button size="small" type="primary" plain @click="handleOffsetClick"> 抵账 </van-button> title="交易详情"
</template> height="75%"
:closeable="false"
>
<van-form style="margin-top: 12px"> <van-form style="margin-top: 12px">
<van-cell-group inset> <van-cell-group inset>
<van-cell title="记录时间" :value="formatDate(transaction.createTime)" /> <van-cell
title="记录时间"
:value="formatDate(transaction.createTime)"
/>
</van-cell-group> </van-cell-group>
<van-cell-group inset title="交易明细"> <van-cell-group
inset
title="交易明细"
>
<van-field <van-field
v-model="occurredAtLabel" v-model="occurredAtLabel"
name="occurredAt" name="occurredAt"
@@ -48,50 +55,68 @@
:rules="[{ required: true, message: '请输入交易后余额' }]" :rules="[{ required: true, message: '请输入交易后余额' }]"
/> />
<van-field name="type" label="交易类型"> <van-field
name="type"
label="交易类型"
>
<template #input> <template #input>
<van-radio-group <van-radio-group
v-model="editForm.type" v-model="editForm.type"
direction="horizontal" direction="horizontal"
@change="handleTypeChange" @change="handleTypeChange"
> >
<van-radio :name="0"> 支出 </van-radio> <van-radio :name="0">
<van-radio :name="1"> 收入 </van-radio> 支出
<van-radio :name="2"> 不计 </van-radio> </van-radio>
<van-radio :name="1">
收入
</van-radio>
<van-radio :name="2">
不计
</van-radio>
</van-radio-group> </van-radio-group>
</template> </template>
</van-field> </van-field>
<van-field name="classify" label="交易分类"> <van-field
name="classify"
label="交易分类"
>
<template #input> <template #input>
<div style="flex: 1"> <div style="flex: 1">
<div <div
v-if=" v-if="
transaction && transaction &&
transaction.unconfirmedClassify && transaction.unconfirmedClassify &&
transaction.unconfirmedClassify !== editForm.classify transaction.unconfirmedClassify !== editForm.classify
" "
class="suggestion-tip" class="suggestion-tip"
@click="applySuggestion" @click="applySuggestion"
> >
<van-icon name="bulb-o" class="suggestion-icon" /> <van-icon
name="bulb-o"
class="suggestion-icon"
/>
<span class="suggestion-text"> <span class="suggestion-text">
建议: {{ transaction.unconfirmedClassify }} 建议: {{ transaction.unconfirmedClassify }}
<span <span
v-if=" v-if="
transaction.unconfirmedType !== null && transaction.unconfirmedType !== null &&
transaction.unconfirmedType !== undefined && transaction.unconfirmedType !== undefined &&
transaction.unconfirmedType !== editForm.type transaction.unconfirmedType !== editForm.type
" "
> >
({{ getTypeName(transaction.unconfirmedType) }}) ({{ getTypeName(transaction.unconfirmedType) }})
</span> </span>
</span> </span>
<div class="suggestion-apply">应用</div> <div class="suggestion-apply">
应用
</div>
</div> </div>
<span v-else-if="!editForm.classify" style="color: var(--van-gray-5)" <span
>请选择交易分类</span v-else-if="!editForm.classify"
> style="color: var(--van-gray-5)"
>请选择交易分类</span>
<span v-else>{{ editForm.classify }}</span> <span v-else>{{ editForm.classify }}</span>
</div> </div>
</template> </template>
@@ -106,30 +131,25 @@
</van-form> </van-form>
<template #footer> <template #footer>
<van-button round block type="primary" :loading="submitting" @click="onSubmit"> <van-button
round
block
type="primary"
:loading="submitting"
@click="onSubmit"
>
保存修改 保存修改
</van-button> </van-button>
</template> </template>
</PopupContainer> </PopupContainer>
<!-- 抵账候选列表弹窗 -->
<PopupContainer v-model="showOffsetPopup" title="选择抵账交易" height="75%">
<van-list>
<van-cell
v-for="item in offsetCandidates"
:key="item.id"
:title="item.reason"
:label="formatDate(item.occurredAt)"
:value="item.amount"
is-link
@click="handleCandidateSelect(item)"
/>
<van-empty v-if="offsetCandidates.length === 0" description="暂无匹配的抵账交易" />
</van-list>
</PopupContainer>
<!-- 日期选择弹窗 --> <!-- 日期选择弹窗 -->
<van-popup v-model:show="showDatePicker" position="bottom" round teleport="body"> <van-popup
v-model:show="showDatePicker"
position="bottom"
round
teleport="body"
>
<van-date-picker <van-date-picker
v-model="currentDate" v-model="currentDate"
title="选择日期" title="选择日期"
@@ -139,7 +159,12 @@
</van-popup> </van-popup>
<!-- 时间选择弹窗 --> <!-- 时间选择弹窗 -->
<van-popup v-model:show="showTimePicker" position="bottom" round teleport="body"> <van-popup
v-model:show="showTimePicker"
position="bottom"
round
teleport="body"
>
<van-time-picker <van-time-picker
v-model="currentTime" v-model="currentTime"
title="选择时间" title="选择时间"
@@ -151,14 +176,12 @@
<script setup> <script setup>
import { ref, reactive, watch, defineProps, defineEmits, computed, nextTick } from 'vue' import { ref, reactive, watch, defineProps, defineEmits, computed, nextTick } from 'vue'
import { showToast, showConfirmDialog } from 'vant' import { showToast } from 'vant'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import PopupContainer from '@/components/PopupContainer.vue' import PopupContainer from '@/components/PopupContainer.vue'
import ClassifySelector from '@/components/ClassifySelector.vue' import ClassifySelector from '@/components/ClassifySelector.vue'
import { import {
updateTransaction, updateTransaction
getCandidatesForOffset,
offsetTransactions
} from '@/api/transactionRecord' } from '@/api/transactionRecord'
const props = defineProps({ const props = defineProps({
@@ -252,7 +275,7 @@ const handleTypeChange = () => {
const onConfirmDate = ({ selectedValues }) => { const onConfirmDate = ({ selectedValues }) => {
const dateStr = selectedValues.join('-') const dateStr = selectedValues.join('-')
const timeStr = currentTime.value.join(':') const timeStr = currentTime.value.join(':')
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).toISOString() editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).format('YYYY-MM-DDTHH:mm:ss')
showDatePicker.value = false showDatePicker.value = false
// 接着选时间 // 接着选时间
showTimePicker.value = true showTimePicker.value = true
@@ -262,7 +285,7 @@ const onConfirmTime = ({ selectedValues }) => {
currentTime.value = selectedValues currentTime.value = selectedValues
const dateStr = currentDate.value.join('-') const dateStr = currentDate.value.join('-')
const timeStr = selectedValues.join(':') const timeStr = selectedValues.join(':')
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).toISOString() editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).format('YYYY-MM-DDTHH:mm:ss')
showTimePicker.value = false showTimePicker.value = false
} }
@@ -341,50 +364,6 @@ const formatDate = (dateString) => {
}) })
} }
// 抵账相关
const showOffsetPopup = ref(false)
const offsetCandidates = ref([])
const handleOffsetClick = async () => {
try {
const res = await getCandidatesForOffset(editForm.id)
if (res.success) {
offsetCandidates.value = res.data || []
showOffsetPopup.value = true
} else {
showToast(res.message || '获取抵账列表失败')
}
} catch (error) {
console.error('获取抵账列表出错:', error)
showToast('获取抵账列表失败')
}
}
const handleCandidateSelect = (candidate) => {
showConfirmDialog({
title: '确认抵账',
message: `确认将当前交易与 "${candidate.reason}" (${candidate.amount}) 互相抵消吗\n抵消后两笔交易将被删除`
})
.then(async () => {
try {
const res = await offsetTransactions(editForm.id, candidate.id)
if (res.success) {
showToast('抵账成功')
showOffsetPopup.value = false
visible.value = false
emit('save') // 触发列表刷新
} else {
showToast(res.message || '抵账失败')
}
} catch (error) {
console.error('抵账出错:', error)
showToast('抵账失败')
}
})
.catch(() => {
// on cancel
})
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,7 +1,16 @@
<template> <template>
<div class="transaction-list-container transaction-list"> <div class="transaction-list-container transaction-list">
<van-list :loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad"> <van-list
<van-cell-group v-if="transactions && transactions.length" inset style="margin-top: 10px"> :loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell-group
v-if="transactions && transactions.length"
inset
style="margin-top: 10px"
>
<van-swipe-cell <van-swipe-cell
v-for="transaction in transactions" v-for="transaction in transactions"
:key="transaction.id" :key="transaction.id"
@@ -14,7 +23,10 @@
class="checkbox-col" class="checkbox-col"
@update:model-value="toggleSelection(transaction)" @update:model-value="toggleSelection(transaction)"
/> />
<div class="transaction-card" @click="handleClick(transaction)"> <div
class="transaction-card"
@click="handleClick(transaction)"
>
<div class="card-left"> <div class="card-left">
<div class="transaction-title"> <div class="transaction-title">
<span class="reason">{{ transaction.reason || '(无摘要)' }}</span> <span class="reason">{{ transaction.reason || '(无摘要)' }}</span>
@@ -26,28 +38,36 @@
<span <span
v-if=" v-if="
transaction.upsetedClassify && transaction.upsetedClassify &&
transaction.upsetedClassify !== transaction.classify transaction.upsetedClassify !== transaction.classify
" "
style="color: var(--van-warning-color)" style="color: var(--van-warning-color)"
> >
→ {{ transaction.upsetedClassify }} → {{ transaction.upsetedClassify }}
</span> </span>
</div> </div>
<div v-if="transaction.importFrom">来源: {{ transaction.importFrom }}</div> <div v-if="transaction.importFrom">
来源: {{ transaction.importFrom }}
</div>
</div> </div>
</div> </div>
<div class="card-middle"> <div class="card-middle">
<van-tag :type="getTypeTagType(transaction.type)" size="medium"> <van-tag
:type="getTypeTagType(transaction.type)"
size="medium"
>
{{ getTypeName(transaction.type) }} {{ getTypeName(transaction.type) }}
</van-tag> </van-tag>
<template <template
v-if=" v-if="
Number.isFinite(transaction.upsetedType) && Number.isFinite(transaction.upsetedType) &&
transaction.upsetedType !== transaction.type transaction.upsetedType !== transaction.type
" "
> >
<van-tag :type="getTypeTagType(transaction.upsetedType)" size="medium"> <van-tag
:type="getTypeTagType(transaction.upsetedType)"
size="medium"
>
{{ getTypeName(transaction.upsetedType) }} {{ getTypeName(transaction.upsetedType) }}
</van-tag> </van-tag>
</template> </template>
@@ -57,21 +77,25 @@
<div :class="['amount', getAmountClass(transaction.type)]"> <div :class="['amount', getAmountClass(transaction.type)]">
{{ formatAmount(transaction.amount, transaction.type) }} {{ formatAmount(transaction.amount, transaction.type) }}
</div> </div>
<div v-if="transaction.balance && transaction.balance > 0" class="balance">
余额: {{ formatMoney(transaction.balance) }}
</div>
<div <div
v-if="transaction.refundAmount && transaction.refundAmount > 0" v-if="transaction.balance && transaction.balance > 0"
class="balance" class="balance"
> >
退款: {{ formatMoney(transaction.refundAmount) }} 余额: {{ formatMoney(transaction.balance) }}
</div> </div>
</div> </div>
<van-icon name="arrow" size="16" color="var(--van-gray-5)" /> <van-icon
name="arrow"
size="16"
color="var(--van-gray-5)"
/>
</div> </div>
</div> </div>
</div> </div>
<template v-if="showDelete" #right> <template
v-if="showDelete"
#right
>
<van-button <van-button
square square
type="danger" type="danger"

View File

@@ -1,4 +1,5 @@
import './assets/main.css' import './assets/main.css'
import './assets/theme.css'
import './styles/common.css' import './styles/common.css'
import './styles/rich-content.css' import './styles/rich-content.css'

View File

@@ -3,13 +3,13 @@
export const needRefresh = ref(false) export const needRefresh = ref(false)
let swRegistration = null let swRegistration = null
export async function updateServiceWorker() { export async function updateServiceWorker () {
if (swRegistration && swRegistration.waiting) { if (swRegistration && swRegistration.waiting) {
await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' }) await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' })
} }
} }
export function register() { export function register () {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
const swUrl = '/service-worker.js' const swUrl = '/service-worker.js'
@@ -66,7 +66,7 @@ export function register() {
} }
} }
export function unregister() { export function unregister () {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready navigator.serviceWorker.ready
.then((registration) => { .then((registration) => {
@@ -79,7 +79,7 @@ export function unregister() {
} }
// 请求通知权限 // 请求通知权限
export function requestNotificationPermission() { export function requestNotificationPermission () {
if ('Notification' in window && 'serviceWorker' in navigator) { if ('Notification' in window && 'serviceWorker' in navigator) {
Notification.requestPermission().then((permission) => { Notification.requestPermission().then((permission) => {
if (permission === 'granted') { if (permission === 'granted') {
@@ -90,7 +90,7 @@ export function requestNotificationPermission() {
} }
// 后台同步 // 后台同步
export function registerBackgroundSync(tag = 'sync-data') { export function registerBackgroundSync (tag = 'sync-data') {
if ('serviceWorker' in navigator && 'SyncManager' in window) { if ('serviceWorker' in navigator && 'SyncManager' in window) {
navigator.serviceWorker.ready navigator.serviceWorker.ready
.then((registration) => { .then((registration) => {

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