82 Commits

Author SHA1 Message Date
孙诚
f6e20df2be 文档 2026-01-14 11:22:03 +08:00
孙诚
1de451c54d 尝试修复 2026-01-14 10:04:30 +08:00
孙诚
db61f70335 使用 maf重构 2026-01-12 14:34:03 +08:00
255c82759e 1
All checks were successful
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
Docker Build & Deploy / Build Docker Image (push) Successful in 16s
2026-01-11 17:00:07 +08:00
386d211735 fix
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 2s
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-11 16:51:55 +08:00
c550e95f94 fix: 设置多个视图的背景色为透明
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 2s
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-11 16:50:18 +08:00
29705bbaa7 fix: 更新样式以使用新的背景颜色变量
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 30s
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-11 16:46:16 +08:00
9fb25bc790 fix style
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 15s
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-11 16:44:32 +08:00
ec460376c6 多个样式调整
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-01-11 16:33:55 +08:00
49023237e7 feat: 更新预算摘要组件以支持日期选择;在预算视图中添加日期绑定,优化数据获取逻辑
All checks were successful
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
Docker Build & Deploy / Build Docker Image (push) Successful in 17s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-11 12:41:52 +08:00
d2b2158b30 feat: 添加获取未被预算覆盖的分类统计信息接口;更新前端以展示未覆盖分类的详细信息
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
2026-01-11 12:33:12 +08:00
e3ea64fb05 feat: 更新确认待确认分类的接口,支持批量确认功能;调整前端逻辑以处理选中的交易记录
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-11 12:02:20 +08:00
d9e9fa9f53 feat: 优化多个组件的高度设置,确保更好的用户体验;更新交易记录控制器以处理智能分类结果
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 28s
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-01-11 11:21:13 +08:00
ad21d20751 feat: 更新未读消息计数的刷新频率,优化消息视图;添加分类标签显示功能,增强预算卡片的可读性
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 19s
Docker Build & Deploy / Deploy to Production (push) Successful in 13s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-01-10 23:01:02 +08:00
171febcfb6 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 39s
Docker Build & Deploy / Deploy to Production (push) Successful in 10s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-01-10 18:14:10 +08:00
9aeb9c825e feat: 添加重试逻辑以增强容器启动和微信通知的可靠性
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 39s
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-10 18:12:01 +08:00
f64e31b230 fix
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 40s
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-10 18:09:09 +08:00
f34457a706 feat: 添加深度复制功能,优化自动分类逻辑;更新周期账单视图以显示下次执行时间
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 43s
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-10 18:04:27 +08:00
6a9c879dee feat: 重构消息服务,替换消息记录服务为消息服务,更新相关依赖和逻辑
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-10 17:47:09 +08:00
b757f18765 feat: 移除预算相关的停止状态属性和相关功能,简化预算管理逻辑
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 24s
Docker Build & Deploy / Deploy to Production (push) Successful in 11s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-01-10 17:38:22 +08:00
037bad2d9b feat: 添加待确认分类功能,支持获取和确认未分类交易记录;优化相关组件和服务
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 24s
Docker Build & Deploy / Deploy to Production (push) Successful in 10s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-01-10 12:22:37 +08:00
50843d43ff feat: 优化预算控制器排序逻辑,修复除零错误;增强程序启动时的JWT认证配置
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 26s
Docker Build & Deploy / Deploy to Production (push) Successful in 19s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-01-10 10:06:39 +08:00
孙诚
76fd0d23dc feat: 更新预算服务和控制器,优化统计信息获取逻辑,增强参数验证
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 25s
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-09 16:59:08 +08:00
孙诚
b5d0524868 feat: 更新交易记录和预算服务,支持按分类和日期范围筛选
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 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-09 16:21:03 +08:00
孙诚
2244d08b43 feat: 添加分类统计功能,支持获取月度和年度预算统计信息
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
2026-01-09 15:42:59 +08:00
孙诚
b41121400d debugger
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 25s
Docker Build & Deploy / Deploy to Production (push) Successful in 12s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-01-09 14:03:45 +08:00
孙诚
ef4ed9fd57 feat: Implement scheduled tasks management and budget archiving functionality
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 6s
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
- Added BudgetArchiveJob for monthly budget archiving.
- Created BudgetArchive entity and BudgetArchiveRepository for managing archived budgets.
- Introduced JobController for handling job execution, pausing, and resuming.
- Developed ScheduledTasksView for displaying and managing scheduled tasks in the frontend.
- Updated PeriodicBillJob to improve scope handling.
- Enhanced OpenAiService with increased HTTP timeout.
- Added archiveBudgets API endpoint for archiving budgets by year and month.
- Refactored BudgetController to utilize new repository patterns and improved error handling.
- Introduced rich-content styles for better rendering of HTML content in Vue components.
- Updated various Vue components to support rich HTML content display.
2026-01-09 14:03:01 +08:00
孙诚
c5363efc0e feat: 移除预算摘要中的预算数量显示
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 17s
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-08 20:56:02 +08:00
孙诚
b05248fc7b feat: 优化预算统计逻辑,支持跨周期统计并修复相关计算问题
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 16s
Docker Build & Deploy / Deploy to Production (push) Successful in 10s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-08 16:59:30 +08:00
孙诚
343570d4bc feat: 更新预算卡片组件,添加预算描述功能并优化状态标签显示
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-01-08 16:03:20 +08:00
孙诚
fcd3a6eb07 修复 PWA 模式下键盘收起页面不回弹的问题
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 15s
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-08 15:23:31 +08:00
孙诚
ab1d216664 feat: 优化预算视图,调整预算摘要组件位置和样式
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 16s
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-01-08 15:16:25 +08:00
孙诚
58ee44987b 封装调整
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 25s
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-01-08 14:41:50 +08:00
孙诚
500a6495bd 1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 18s
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-08 12:18:00 +08:00
孙诚
faa5a49553 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 30s
Docker Build & Deploy / Deploy to Production (push) Successful in 12s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
2026-01-07 20:36:58 +08:00
孙诚
aa8fc7a8b3 feat: 添加存款分类设置功能,优化预算管理界面
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 26s
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-01-07 20:31:12 +08:00
孙诚
a1bb7ad5e1 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 15s
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-07 19:57:43 +08:00
孙诚
fa1389401a feat: 添加预算类别名称更新功能,优化相关控制器逻辑
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-07 19:55:00 +08:00
孙诚
35a856c6e3 feat: 优化预算管理界面,增强预算编辑功能,添加预算删除接口
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-07 19:19:53 +08:00
孙诚
851886a601 fix notify
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 11s
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 1s
2026-01-07 17:40:22 +08:00
孙诚
eec3702ce7 add: notify 2026-01-07 17:39:05 +08:00
孙诚
620effd1f8 feat: add budget update functionality and enhance budget management UI
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 26s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
- Implemented UpdateAsync method in BudgetController for updating budget details.
- Created UpdateBudgetDto for handling budget update requests.
- Added BudgetCard component for displaying budget information with progress tracking.
- Developed BudgetEditPopup component for creating and editing budget entries.
- Introduced BudgetSummary component for summarizing budget statistics by period.
- Enhanced budget period display logic in BudgetDto to support various timeframes.
2026-01-07 17:33:50 +08:00
孙诚
60fb0e0d8f feat: 移除预算同步相关功能,简化预算管理逻辑
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 2s
2026-01-07 16:23:50 +08:00
孙诚
c95aa6b17b feat: 更新 Service Worker 版本管理,优化缓存策略,增加未处理更新的提示
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 15s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-07 16:07:56 +08:00
孙诚
1bd6b688c1 优化预算管理页面,移除冗余的底部安全距离元素
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 6s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-07 15:00:55 +08:00
孙诚
fcf122c806 优化预算管理页面布局,调整导航栏和内容区域,增加底部安全距离
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 14s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 0s
2026-01-07 14:36:30 +08:00
孙诚
b2339c1c5e feat: update VSCode settings for ESLint and Prettier integration
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
chore: refactor ESLint configuration for improved linting rules and performance

fix: handle push event data parsing in service worker

style: adjust tabbar item properties for better readability in App.vue

refactor: remove unused functions and improve code clarity in TransactionDetail.vue

fix: ensure consistent event handling in CalendarView.vue

style: clean up component structure and formatting in various Vue files

chore: update launch script for better command execution

feat: add ESLint configuration file for consistent code style across the project

fix: resolve issues with button click events in multiple components
2026-01-07 14:33:30 +08:00
孙诚
efdfe88155 添加预算视图统计信息,显示本周、本月和年度达成率,优化样式和布局
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 16s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-06 21:23:04 +08:00
孙诚
343c754431 重构预算管理模块,添加预算记录和服务,更新相关API,优化预算统计逻辑
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
2026-01-06 21:15:02 +08:00
孙诚
0ca7f44e37 添加预算管理功能,重构账单和消息视图,优化路由和组件交互
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 16s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-06 20:55:11 +08:00
孙诚
10b02df6e2 添加消息类型枚举和相关字段,优化消息记录服务的添加方法,更新多个组件以支持新增分类对话框
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 38s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-06 13:45:39 +08:00
孙诚
baa77341bc 优化底部操作栏样式,调整布局和背景色,并在分类编辑页面添加安全距离
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
2026-01-05 19:07:10 +08:00
孙诚
826f139fad 修正提示信息,要求用户必须从分类列表中选择分类
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
2026-01-05 17:13:35 +08:00
孙诚
d311918b0b 优化SQL查询语句的HTML输出样式,调整字体大小并添加边框
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
2026-01-05 16:20:21 +08:00
孙诚
83d8c25aca 优化SQL查询语句的HTML输出样式,调整最大高度为80px
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
2026-01-05 16:19:04 +08:00
孙诚
18f0313d01 优化生成SQL查询语句的HTML输出格式,使用JSON序列化包装内容
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
2026-01-05 15:57:22 +08:00
孙诚
34c6416538 重构分类信息构建逻辑,提取为独立方法并优化代码结构
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-05 15:51:47 +08:00
孙诚
d7274cd54d 添加生成SQL查询语句的HTML格式化输出
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-05 15:35:57 +08:00
孙诚
d44cceb6e4 添加配置管理功能,包括获取和设置配置值的接口及实现
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
2026-01-05 15:21:13 +08:00
孙诚
5a824dac91 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 17s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
2026-01-04 19:54:17 +08:00
孙诚
5720bac683 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 18s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-04 18:51:55 +08:00
孙诚
3ec0dbcd9b 优化交易分类选择逻辑,调整条件判断并移除高亮样式
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Successful in 15s
Docker Build & Deploy / Deploy to Production (push) Failing after 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Has been skipped
2026-01-04 18:33:36 +08:00
孙诚
9ddf7e3dbd fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 18s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
2026-01-04 18:24:39 +08:00
孙诚
36126097fa 重命名 PushSubscriptionEntity 为 PushSubscription,并更新相关引用
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 0s
2026-01-04 16:59:34 +08:00
孙诚
14296d65d1 优化交易分类选择时的自动保存功能;重命名清除缓存为刷新网络并更新相关逻辑
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 15s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
2026-01-04 16:52:20 +08:00
孙诚
557d44aed8 优化代码
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
2026-01-04 16:43:32 +08:00
ab22325ca7 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 14s
Docker Build & Deploy / Deploy to Production (push) Successful in 5s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-03 12:05:25 +08:00
53d8470e88 优化邮件处理记录的导入来源信息,简化通知内容
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
2026-01-03 12:04:26 +08:00
5c108d27df 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 1s
2026-01-03 11:30:51 +08:00
f45e4a02c3 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 16s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
2026-01-03 11:26:50 +08:00
82bb13c385 统一组件
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 19s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
2026-01-03 11:07:33 +08:00
f0d332a503 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 15s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
2026-01-02 19:25:40 +08:00
f5c41c8be4 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 15s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
2026-01-02 19:21:47 +08:00
9bbddfc0b1 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 16s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
2026-01-02 19:17:21 +08:00
4016e05e40 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 14s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
2026-01-02 19:03:22 +08:00
7704a04429 修复问题
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 16s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
2026-01-02 18:58:07 +08:00
5c76e9287e fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 37s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
2026-01-02 18:51:28 +08:00
e250a7df2f feat: 添加推送通知功能,支持订阅和发送通知
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 40s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
2026-01-02 12:25:44 +08:00
a055e43451 feat: 更新智能账单解析助手的分类信息提示,增强用户体验
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 13s
2026-01-01 21:22:58 +08:00
8c72102e87 feat: 添加分类名称更新功能,支持在交易记录中同步更新分类名称
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 23s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
2026-01-01 15:20:59 +08:00
c58404491f 优化 Docker 镜像构建过程,增加重试机制以提高构建稳定性
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Successful in 1m53s
Docker Build & Deploy / Deploy to Production (push) Failing after 6s
2026-01-01 14:45:12 +08:00
e00b5cffb7 feat: Refactor transaction handling and add new features
- Updated ReasonGroupList.vue to modify classify button behavior for adding new classifications.
- Refactored TransactionDetail.vue to integrate PopupContainer and enhance transaction detail display.
- Improved TransactionDetailDialog.vue with updated classify button functionality.
- Simplified BalanceView.vue by removing manual entry button.
- Enhanced PeriodicRecord.vue to update classify button interactions.
- Removed unused add transaction dialog from TransactionsRecord.vue.
- Added new API endpoints in TransactionRecordController for parsing transactions and handling offsets.
- Introduced BillForm.vue and ManualBillAdd.vue for streamlined bill entry.
- Implemented OneLineBillAdd.vue for intelligent transaction parsing.
- Created GlobalAddBill.vue for a unified bill addition interface.
2026-01-01 14:43:43 +08:00
127 changed files with 9472 additions and 2584 deletions

198
.eslintrc.js Normal file
View File

@@ -0,0 +1,198 @@
module.exports = {
root: true,
parserOptions: {
parser: 'babel-eslint',
sourceType: 'module'
},
env: {
browser: true,
node: true,
es6: true,
},
extends: ['plugin:vue/recommended', 'eslint:recommended'],
// add your custom rules here
//it is base on https://github.com/vuejs/eslint-config-vue
rules: {
"vue/max-attributes-per-line": [2, {
"singleline": 10,
// "multiline": {
// "max": 1,
// "allowFirstLine": false
// }
}],
"vue/singleline-html-element-content-newline": "off",
"vue/multiline-html-element-content-newline":"off",
"vue/name-property-casing": ["error", "PascalCase"],
"vue/no-v-html": "off",
'accessor-pairs': 2,
'arrow-spacing': [2, {
'before': true,
'after': true
}],
'block-spacing': [2, 'always'],
'brace-style': [2, '1tbs', {
'allowSingleLine': true
}],
'camelcase': [0, {
'properties': 'always'
}],
'comma-dangle': [2, 'never'],
'comma-spacing': [2, {
'before': false,
'after': true
}],
'comma-style': [2, 'last'],
'constructor-super': 2,
'curly': [2, 'multi-line'],
'dot-location': [2, 'property'],
'eol-last': 2,
'eqeqeq': ["error", "always", {"null": "ignore"}],
'generator-star-spacing': [2, {
'before': true,
'after': true
}],
'handle-callback-err': [2, '^(err|error)$'],
'indent': [2, 2, {
'SwitchCase': 1
}],
'jsx-quotes': [2, 'prefer-single'],
'key-spacing': [2, {
'beforeColon': false,
'afterColon': true
}],
'keyword-spacing': [2, {
'before': true,
'after': true
}],
'new-cap': [2, {
'newIsCap': true,
'capIsNew': false
}],
'new-parens': 2,
'no-array-constructor': 2,
'no-caller': 2,
'no-console': 'off',
'no-class-assign': 2,
'no-cond-assign': 2,
'no-const-assign': 2,
'no-control-regex': 0,
'no-delete-var': 2,
'no-dupe-args': 2,
'no-dupe-class-members': 2,
'no-dupe-keys': 2,
'no-duplicate-case': 2,
'no-empty-character-class': 2,
'no-empty-pattern': 2,
'no-eval': 2,
'no-ex-assign': 2,
'no-extend-native': 2,
'no-extra-bind': 2,
'no-extra-boolean-cast': 2,
'no-extra-parens': [2, 'functions'],
'no-fallthrough': 2,
'no-floating-decimal': 2,
'no-func-assign': 2,
'no-implied-eval': 2,
'no-inner-declarations': [2, 'functions'],
'no-invalid-regexp': 2,
'no-irregular-whitespace': 2,
'no-iterator': 2,
'no-label-var': 2,
'no-labels': [2, {
'allowLoop': false,
'allowSwitch': false
}],
'no-lone-blocks': 2,
'no-mixed-spaces-and-tabs': 2,
'no-multi-spaces': 2,
'no-multi-str': 2,
'no-multiple-empty-lines': [2, {
'max': 1
}],
'no-native-reassign': 2,
'no-negated-in-lhs': 2,
'no-new-object': 2,
'no-new-require': 2,
'no-new-symbol': 2,
'no-new-wrappers': 2,
'no-obj-calls': 2,
'no-octal': 2,
'no-octal-escape': 2,
'no-path-concat': 2,
'no-proto': 2,
'no-redeclare': 2,
'no-regex-spaces': 2,
'no-return-assign': [2, 'except-parens'],
'no-self-assign': 2,
'no-self-compare': 2,
'no-sequences': 2,
'no-shadow-restricted-names': 2,
'no-spaced-func': 2,
'no-sparse-arrays': 2,
'no-this-before-super': 2,
'no-throw-literal': 2,
'no-trailing-spaces': 2,
'no-undef': 2,
'no-undef-init': 2,
'no-unexpected-multiline': 2,
'no-unmodified-loop-condition': 2,
'no-unneeded-ternary': [2, {
'defaultAssignment': false
}],
'no-unreachable': 2,
'no-unsafe-finally': 2,
'no-unused-vars': [2, {
'vars': 'all',
'args': 'none'
}],
'no-useless-call': 2,
'no-useless-computed-key': 2,
'no-useless-constructor': 2,
'no-useless-escape': 0,
'no-whitespace-before-property': 2,
'no-with': 2,
'one-var': [2, {
'initialized': 'never'
}],
'operator-linebreak': [2, 'after', {
'overrides': {
'?': 'before',
':': 'before'
}
}],
'padded-blocks': [2, 'never'],
'quotes': [2, 'single', {
'avoidEscape': true,
'allowTemplateLiterals': true
}],
'semi': [2, 'never'],
'semi-spacing': [2, {
'before': false,
'after': true
}],
'space-before-blocks': [2, 'always'],
'space-before-function-paren': [2, 'never'],
'space-in-parens': [2, 'never'],
'space-infix-ops': 2,
'space-unary-ops': [2, {
'words': true,
'nonwords': false
}],
'spaced-comment': [2, 'always', {
'markers': ['global', 'globals', 'eslint', 'eslint-disable', '*package', '!', ',']
}],
'template-curly-spacing': [2, 'never'],
'use-isnan': 2,
'valid-typeof': 2,
'wrap-iife': [2, 'any'],
'yield-star-spacing': [2, 'both'],
'yoda': [2, 'never'],
'prefer-const': 2,
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
'object-curly-spacing': [2, 'always', {
objectsInObjects: false
}],
'array-bracket-spacing': [2, 'never']
}
}

View File

@@ -15,7 +15,7 @@ jobs:
steps:
# ✅ 使用 Gitea 兼容的代码检出方式
- name: Checkout code
uses: actions/checkout@v3
uses: https://gitea.com/actions/checkout@v3
with:
gitea-server: http://192.168.31.14:14200
token: ${{ secrets.GITEA_TOKEN }}
@@ -27,7 +27,19 @@ jobs:
docker rmi $IMAGE_NAME || true
- name: Build new image
run: docker build -t $IMAGE_NAME .
run: |
RETRIES=3
DELAY=10
count=0
until docker build -t $IMAGE_NAME .; do
count=$((count+1))
if [ $count -ge $RETRIES ]; then
echo "Build failed after $RETRIES attempts"
exit 1
fi
echo "Build failed. Retrying in $DELAY seconds... ($count/$RETRIES)"
sleep $DELAY
done
deploy:
name: Deploy to Production
@@ -41,4 +53,76 @@ jobs:
- name: Start containers
run: |
docker compose -p $COMPOSE_PROJECT_NAME down
docker compose -p $COMPOSE_PROJECT_NAME up -d --build
# 添加重试逻辑
RETRIES=3
DELAY=10
count=0
until docker compose -p $COMPOSE_PROJECT_NAME up -d --build; do
count=$((count+1))
if [ $count -ge $RETRIES ]; then
echo "Deployment failed after $RETRIES attempts"
exit 1
fi
echo "Deployment failed. Retrying in $DELAY seconds... ($count/$RETRIES)"
sleep $DELAY
done
cleanup:
name: Cleanup Dangling Images
runs-on: ubuntu-latest
needs: deploy
if: always()
steps:
- name: Remove dangling images
run: |
docker rm $(docker images -f "dangling=true" -q) || true
echo "Cleanup completed."
notify:
name: WeChat Notification
runs-on: ubuntu-latest
needs: [build, deploy]
if: always()
steps:
- name: Send WeChat Notification
run: |
# 判断结果:只有 build 和 deploy 都成功才算成功
if [[ "${{ needs.build.result }}" == "success" && "${{ needs.deploy.result }}" == "success" ]]; then
MSG_TITLE="构建并部署成功"
RESULT_ICON="✅"
MSG_COLOR="info"
else
MSG_TITLE="自动部署发现异常"
RESULT_ICON="❌"
MSG_COLOR="warning"
fi
WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=96f9fa23-a959-4492-ac3a-7415fae19680"
# 准备 Markdown 内容
cat <<EOF > wechat_payload.json
{
"msgtype": "markdown",
"markdown": {
"content": "### $RESULT_ICON $MSG_TITLE\n> **项目**: [${{ gitea.repository }}](${{ gitea.server_url }}/${{ gitea.repository }})\n> **状态**: <font color=\"$MSG_COLOR\">$MSG_TITLE</font>\n> **分支**: \`${{ gitea.ref_name }}\`\n> **触发者**: ${{ gitea.actor }}\n> **任务编号**: [#${{ gitea.run_number }}](${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }})\n> **构建状态**: ${{ needs.build.result || 'skipped' }}\n> **部署状态**: ${{ needs.deploy.result || 'skipped' }}\n> **提交详情**: [${{ gitea.sha }}](${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }})"
}
}
EOF
# 添加重试逻辑
RETRIES=3
DELAY=5
count=0
# 使用 -f 让 curl 在 HTTP 错误时返回非零退出码
until curl -s -f -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d @wechat_payload.json; do
count=$((count+1))
if [ $count -ge $RETRIES ]; then
echo "WeChat notification failed after $RETRIES attempts"
exit 1
fi
echo "Notification failed. Retrying in $DELAY seconds... ($count/$RETRIES)"
sleep $DELAY
done

6
.github/csharpe.prompt.md vendored Normal file
View File

@@ -0,0 +1,6 @@
# C# Developer Prompt
- 优先使用新C#语法
- 优先使用中文注释
- 优先复用已有方法
- 不要深嵌套代码
- 保持代码简洁易读

2
.gitignore vendored
View File

@@ -402,3 +402,5 @@ FodyWeavers.xsd
.idea/
Web/dist
# ESLint
.eslintcache

29
.sql/init_categories.sql Normal file
View File

@@ -0,0 +1,29 @@
-- 支出分类 (Type = 0)
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10001, '2026-01-01 00:00:00', 'C餐饮美食', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10002, '2026-01-01 00:00:00', 'F服饰美容', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10003, '2026-01-01 00:00:00', 'S生活日用', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10004, '2026-01-01 00:00:00', 'G交通出行', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10005, '2026-01-01 00:00:00', 'X休闲娱乐', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10006, '2026-01-01 00:00:00', 'Y医疗保健', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10007, '2026-01-01 00:00:00', 'Z住房物业', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10008, '2026-01-01 00:00:00', 'S水电煤气', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10009, '2026-01-01 00:00:00', 'T通讯物流', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10010, '2026-01-01 00:00:00', 'S学习教育', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10011, '2026-01-01 00:00:00', 'R人情往来', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10012, '2026-01-01 00:00:00', 'Q其他支出', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10013, '2026-01-01 00:00:00', 'N奶茶咖啡', 0);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (10014, '2026-01-01 00:00:00', 'D钻石福袋', 0);
-- 收入分类 (Type = 1)
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11001, '2026-01-01 00:00:00', 'G工资薪金', 1);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11002, '2026-01-01 00:00:00', 'J奖金补贴', 1);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11003, '2026-01-01 00:00:00', 'L理财收益', 1);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11004, '2026-01-01 00:00:00', 'J兼职收入', 1);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11005, '2026-01-01 00:00:00', 'L礼金收入', 1);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11006, '2026-01-01 00:00:00', 'Q其他收入', 1);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (11007, '2026-01-01 00:00:00', 'X闲鱼收入', 1);
-- 不记收支分类 (Type = 2)
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (12001, '2026-01-01 00:00:00', 'Z转账给自己', 2);
INSERT INTO TransactionCategory (Id, CreateTime, Name, Type) VALUES (12002, '2026-01-01 00:00:00', 'Z转账到公共', 2);

13
.vscode/settings.json vendored
View File

@@ -1,3 +1,14 @@
{
"vue3snippets.enable-compile-vue-file-on-did-save-code": false
"vue3snippets.enable-compile-vue-file-on-did-save-code": false,
"eslint.workingDirectories": [
"./Web"
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"vue"
]
}

View File

@@ -3,6 +3,18 @@ using Microsoft.Extensions.DependencyInjection;
namespace Common;
public static class TypeExtensions
{
/// <summary>
/// 深度复制对象属性到目标对象
/// </summary>
public static T? DeepClone<T>(this T source)
{
var json = System.Text.Json.JsonSerializer.Serialize(source);
return System.Text.Json.JsonSerializer.Deserialize<T>(json);
}
}
/// <summary>
/// 服务依赖注入扩展
/// </summary>
@@ -38,20 +50,9 @@ public static class ServiceExtension
foreach (var @interface in interfaces)
{
// EmailBackgroundService 必须是 Singleton(后台服务),其他服务可用 Transient
if (type.Name == "EmailBackgroundService")
{
// 其他 Services 用 Singleton
services.AddSingleton(@interface, type);
}
else if (type.Name == "EmailFetchService")
{
// EmailFetchService 用 Transient避免连接冲突
services.AddTransient(@interface, type);
}
else
{
services.AddSingleton(@interface, type);
}
Console.WriteLine($"✓ 注册 Service: {@interface.Name} -> {type.Name}");
}
}
}
@@ -71,7 +72,7 @@ public static class ServiceExtension
foreach (var @interface in interfaces)
{
services.AddSingleton(@interface, type);
Console.WriteLine($"注册 Repository: {@interface.Name} -> {type.Name}");
Console.WriteLine($"注册 Repository: {@interface.Name} -> {type.Name}");
}
}
}

View File

@@ -3,6 +3,9 @@
<!-- Email & MIME Libraries -->
<PackageVersion Include="FreeSql" Version="3.5.304" />
<PackageVersion Include="MailKit" Version="4.14.1" />
<PackageVersion Include="Microsoft.Agents.AI" Version="1.0.0-preview.260108.1" />
<PackageVersion Include="Microsoft.Agents.AI.DevUI" Version="1.0.0-preview.260108.1" />
<PackageVersion Include="Microsoft.Agents.AI.Hosting" Version="1.0.0-preview.260108.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageVersion Include="MimeKit" Version="4.14.0" />
<!-- Dependency Injection & Configuration -->
@@ -21,6 +24,7 @@
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
<!-- Database -->
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.304" />
<PackageVersion Include="WebPush" Version="1.0.12" />
<PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" />
<!-- File Processing -->
<PackageVersion Include="CsvHelper" Version="33.0.1" />
@@ -32,5 +36,6 @@
<!-- Text Processing -->
<PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.1.1" />
</ItemGroup>
</Project>

View File

@@ -15,6 +15,10 @@ RUN pnpm run build
# 第二阶段:构建后端
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend-build
# 禁用遥测和减少并行度以尝试修复 exit code 134 (常见于内存受限环境下的崩溃)
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
WORKDIR /app
# 复制解决方案文件和项目文件
@@ -39,7 +43,8 @@ COPY Service/ ./Service/
COPY WebApi/ ./WebApi/
# 构建并发布
RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish
# 使用 -m:1 限制 CPU/内存并行度,减少容器构建崩溃风险
RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish --no-restore -m:1
# 将前端构建产物复制到后端的 wwwroot 目录
COPY --from=frontend-build /app/frontend/dist /app/publish/wwwroot

44
Entity/BudgetArchive.cs Normal file
View File

@@ -0,0 +1,44 @@
namespace Entity;
public class BudgetArchive : BaseEntity
{
/// <summary>
/// 预算Id
/// </summary>
public long BudgetId { get; set; }
/// <summary>
/// 预算周期类型
/// </summary>
public BudgetPeriodType BudgetType { get; set; }
/// <summary>
/// 预算金额
/// </summary>
public decimal BudgetedAmount { get; set; }
/// <summary>
/// 周期内实际发生金额
/// </summary>
public decimal RealizedAmount { get; set; }
/// <summary>
/// 详细描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 归档目标年份
/// </summary>
public int Year { get; set; }
/// <summary>
/// 归档目标月份
/// </summary>
public int Month { get; set; }
/// <summary>
/// 归档日期
/// </summary>
public DateTime ArchiveDate { get; set; } = DateTime.Now;
}

65
Entity/BudgetRecord.cs Normal file
View File

@@ -0,0 +1,65 @@
namespace Entity;
/// <summary>
/// 预算管理
/// </summary>
public class BudgetRecord : BaseEntity
{
/// <summary>
/// 预算名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 统计周期
/// </summary>
public BudgetPeriodType Type { get; set; } = BudgetPeriodType.Month;
/// <summary>
/// 预算金额
/// </summary>
public decimal Limit { get; set; }
/// <summary>
/// 预算类别
/// </summary>
public BudgetCategory Category { get; set; }
/// <summary>
/// 相关分类 (逗号分隔的分类名称)
/// </summary>
public string SelectedCategories { get; set; } = string.Empty;
/// <summary>
/// 开始日期
/// </summary>
public DateTime StartDate { get; set; } = DateTime.Now;
}
public enum BudgetPeriodType
{
/// <summary>
/// 月
/// </summary>
Month = 1,
/// <summary>
/// 年
/// </summary>
Year = 2
}
public enum BudgetCategory
{
/// <summary>
/// 支出
/// </summary>
Expense = 0,
/// <summary>
/// 收入
/// </summary>
Income = 1,
/// <summary>
/// 存款
/// </summary>
Savings = 2
}

31
Entity/ConfigEntity.cs Normal file
View File

@@ -0,0 +1,31 @@
namespace Entity;
/// <summary>
/// 配置实体
/// </summary>
[Table(Name = "Config")]
public class ConfigEntity : BaseEntity
{
/// <summary>
/// 配置Key
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// 配置Value
/// </summary>
public string Value { get; set; } = string.Empty;
/// <summary>
/// 配置类型
/// </summary>
public ConfigType Type { get; set; }
}
public enum ConfigType
{
Boolean,
String,
Json,
Number
}

View File

@@ -1,5 +1,24 @@
namespace Entity;
/// <summary>
/// 消息类型
/// </summary>
public enum MessageType
{
/// <summary>
/// 文本
/// </summary>
Text = 0,
/// <summary>
/// 跳转URL
/// </summary>
Url = 1,
/// <summary>
/// HTML内容
/// </summary>
Html = 2
}
/// <summary>
/// 消息实体
/// </summary>
@@ -15,8 +34,18 @@ public class MessageRecord : BaseEntity
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 消息类型
/// </summary>
public MessageType MessageType { get; set; } = MessageType.Text;
/// <summary>
/// 是否已读
/// </summary>
public bool IsRead { get; set; } = false;
/// <summary>
/// 跳转URL
/// </summary>
public string? Url { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace Entity;
public class PushSubscription : BaseEntity
{
[Required]
public string Endpoint { get; set; } = string.Empty;
public string? P256DH { get; set; }
public string? Auth { get; set; }
public string? UserId { get; set; } // Optional: if you have user authentication
public string? UserAgent { get; set; }
}

View File

@@ -50,6 +50,16 @@ public class TransactionRecord : BaseEntity
/// </summary>
public string Classify { get; set; } = string.Empty;
/// <summary>
/// 待确认的分类AI或规则建议但尚未正式确认
/// </summary>
public string? UnconfirmedClassify { get; set; }
/// <summary>
/// 待确认的类型
/// </summary>
public TransactionType? UnconfirmedType { get; set; }
/// <summary>
/// 导入编号
/// </summary>

View File

@@ -45,6 +45,13 @@ public interface IBaseRepository<T> where T : BaseEntity
/// 删除数据
/// </summary>
Task<bool> DeleteAsync(long id);
/// <summary>
/// 执行动态SQL查询返回动态对象
/// </summary>
/// <param name="completeSql">完整的SELECT SQL语句</param>
/// <returns>动态查询结果列表</returns>
Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql);
}
@@ -157,4 +164,22 @@ public abstract class BaseRepository<T>(IFreeSql freeSql) : IBaseRepository<T> w
return false;
}
}
public async Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql)
{
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
var result = new List<dynamic>();
foreach (System.Data.DataRow row in dt.Rows)
{
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
foreach (System.Data.DataColumn column in dt.Columns)
{
expando[column.ColumnName] = row[column];
}
result.Add(expando);
}
return result;
}
}

View File

@@ -0,0 +1,34 @@
namespace Repository;
public interface IBudgetArchiveRepository : IBaseRepository<BudgetArchive>
{
Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month);
Task<List<BudgetArchive>> GetListAsync(int year, int month);
}
public class BudgetArchiveRepository(
IFreeSql freeSql
) : BaseRepository<BudgetArchive>(freeSql), IBudgetArchiveRepository
{
public async Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month)
{
return await FreeSql.Select<BudgetArchive>()
.Where(a => a.BudgetId == budgetId &&
a.Year == year &&
a.Month == month)
.ToOneAsync();
}
public async Task<List<BudgetArchive>> GetListAsync(int year, int month)
{
return await FreeSql.Select<BudgetArchive>()
.Where(
a => a.BudgetType == BudgetPeriodType.Month &&
a.Year == year &&
a.Month == month ||
a.BudgetType == BudgetPeriodType.Year &&
a.Year == year
)
.ToListAsync();
}
}

View File

@@ -0,0 +1,65 @@
namespace Repository;
public interface IBudgetRepository : IBaseRepository<BudgetRecord>
{
Task<decimal> GetCurrentAmountAsync(BudgetRecord budget, DateTime startDate, DateTime endDate);
Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type);
}
public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(freeSql), IBudgetRepository
{
public async Task<decimal> GetCurrentAmountAsync(BudgetRecord budget, DateTime startDate, DateTime endDate)
{
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);
}
else if (budget.Category == BudgetCategory.Savings)
{
query = query.Where(t => t.Type == TransactionType.None);
}
return await query.SumAsync(t => t.Amount);
}
public async Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type)
{
var records = await FreeSql.Select<BudgetRecord>()
.Where(b => b.SelectedCategories.Contains(oldName) &&
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
(type == TransactionType.Income && b.Category == BudgetCategory.Income) ||
(type == TransactionType.None && b.Category == BudgetCategory.Savings)))
.ToListAsync();
foreach (var record in records)
{
var categories = record.SelectedCategories.Split(',').ToList();
for (int i = 0; i < categories.Count; i++)
{
if (categories[i] == oldName)
{
categories[i] = newName;
}
}
record.SelectedCategories = string.Join(',', categories);
}
await FreeSql.Update<BudgetRecord>()
.SetSource(records)
.ExecuteAffrowsAsync();
}
}

View File

@@ -0,0 +1,19 @@
namespace Repository;
public interface IConfigRepository : IBaseRepository<ConfigEntity>
{
/// <summary>
/// 根据Key获取配置
/// </summary>
Task<ConfigEntity?> GetByKeyAsync(string key);
}
public class ConfigRepository(IFreeSql freeSql) : BaseRepository<ConfigEntity>(freeSql), IConfigRepository
{
public async Task<ConfigEntity?> GetByKeyAsync(string key)
{
return await FreeSql.Select<ConfigEntity>()
.Where(c => c.Key == key)
.FirstAsync();
}
}

View File

@@ -0,0 +1,16 @@
namespace Repository;
public interface IPushSubscriptionRepository : IBaseRepository<PushSubscription>
{
Task<PushSubscription?> GetByEndpointAsync(string endpoint);
}
public class PushSubscriptionRepository(IFreeSql freeSql) : BaseRepository<PushSubscription>(freeSql), IPushSubscriptionRepository
{
public async Task<PushSubscription?> GetByEndpointAsync(string endpoint)
{
return await FreeSql.Select<PushSubscription>()
.Where(x => x.Endpoint == endpoint)
.FirstAsync();
}
}

View File

@@ -12,10 +12,12 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// <param name="pageIndex">页码从1开始</param>
/// <param name="pageSize">每页数量</param>
/// <param name="searchKeyword">搜索关键词(搜索交易摘要和分类)</param>
/// <param name="classify">筛选分类</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>
@@ -23,10 +25,12 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
int pageIndex = 1,
int pageSize = 20,
string? searchKeyword = null,
string? classify = null,
string[]? classifies = null,
TransactionType? type = null,
int? year = null,
int? month = null,
DateTime? startDate = null,
DateTime? endDate = null,
string? reason = null,
bool sortByAmount = false);
@@ -35,10 +39,12 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// </summary>
Task<long> GetTotalCountAsync(
string? searchKeyword = null,
string? classify = null,
string[]? classifies = null,
TransactionType? type = null,
int? year = null,
int? month = null,
DateTime? startDate = null,
DateTime? endDate = null,
string? reason = null);
/// <summary>
@@ -146,13 +152,6 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// <returns>查询结果列表</returns>
Task<List<TransactionRecord>> ExecuteRawSqlAsync(string completeSql);
/// <summary>
/// 执行动态SQL查询返回动态对象
/// </summary>
/// <param name="completeSql">完整的SELECT SQL语句</param>
/// <returns>动态查询结果列表</returns>
Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql);
/// <summary>
/// 根据关键词查询已分类的账单(用于智能分类参考)
/// </summary>
@@ -169,6 +168,36 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// <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();
/// <summary>
/// 全部确认待确认的分类
/// </summary>
/// <returns>影响行数</returns>
Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
/// <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);
}
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
@@ -191,10 +220,12 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
int pageIndex = 1,
int pageSize = 20,
string? searchKeyword = null,
string? classify = null,
string[]? classifies = null,
TransactionType? type = null,
int? year = null,
int? month = null,
DateTime? startDate = null,
DateTime? endDate = null,
string? reason = null,
bool sortByAmount = false)
{
@@ -210,13 +241,10 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
t => t.Reason == reason);
// 按分类筛选
if (!string.IsNullOrWhiteSpace(classify))
if (classifies != null && classifies.Length > 0)
{
if (classify == "未分类")
{
classify = string.Empty;
}
query = query.Where(t => t.Classify == classify);
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
query = query.Where(t => filterClassifies.Contains(t.Classify));
}
// 按交易类型筛选
@@ -225,11 +253,15 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
// 按年月筛选
if (year.HasValue && month.HasValue)
{
var startDate = new DateTime(year.Value, month.Value, 1);
var endDate = startDate.AddMonths(1);
query = query.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate);
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)
{
@@ -253,10 +285,12 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
public async Task<long> GetTotalCountAsync(
string? searchKeyword = null,
string? classify = null,
string[]? classifies = null,
TransactionType? type = null,
int? year = null,
int? month = null,
DateTime? startDate = null,
DateTime? endDate = null,
string? reason = null)
{
var query = FreeSql.Select<TransactionRecord>();
@@ -271,13 +305,10 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
t => t.Reason == reason);
// 按分类筛选
if (!string.IsNullOrWhiteSpace(classify))
if (classifies != null && classifies.Length > 0)
{
if (classify == "未分类")
{
classify = string.Empty;
}
query = query.Where(t => t.Classify == classify);
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
query = query.Where(t => filterClassifies.Contains(t.Classify));
}
// 按交易类型筛选
@@ -286,11 +317,15 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
// 按年月筛选
if (year.HasValue && month.HasValue)
{
var startDate = new DateTime(year.Value, month.Value, 1);
var endDate = startDate.AddMonths(1);
query = query.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate);
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();
}
@@ -441,23 +476,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
return await FreeSql.Ado.QueryAsync<TransactionRecord>(completeSql);
}
public async Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql)
{
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
var result = new List<dynamic>();
foreach (System.Data.DataRow row in dt.Rows)
{
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
foreach (System.Data.DataColumn column in dt.Columns)
{
expando[column.ColumnName] = row[column];
}
result.Add(expando);
}
return result;
}
public async Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month)
{
var startDate = new DateTime(year, month, 1);
@@ -636,6 +654,61 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
return scoredResults;
}
public async Task<List<TransactionRecord>> GetCandidatesForOffsetAsync(long currentId, decimal amount, TransactionType currentType)
{
var absAmount = Math.Abs(amount);
var minAmount = absAmount - 5;
var maxAmount = absAmount + 5;
var currentRecord = await FreeSql.Select<TransactionRecord>()
.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)
{
return await FreeSql.Update<TransactionRecord>()
.Set(a => a.Classify, newName)
.Where(a => a.Classify == oldName && a.Type == type)
.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)
{
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();
}
}
/// <summary>

View File

@@ -0,0 +1,70 @@
namespace Service.AgentFramework;
/// <summary>
/// AI 工具集
/// </summary>
public interface IAITools
{
/// <summary>
/// AI 分类决策
/// </summary>
Task<ClassificationResult[]> ClassifyTransactionsAsync(
string systemPrompt,
string userPrompt);
}
/// <summary>
/// AI 工具实现
/// </summary>
public class AITools(
IOpenAiService openAiService,
ILogger<AITools> logger
) : IAITools
{
public async Task<ClassificationResult[]> ClassifyTransactionsAsync(
string systemPrompt,
string userPrompt)
{
logger.LogInformation("调用 AI 进行账单分类");
var response = await openAiService.ChatAsync(systemPrompt, userPrompt);
if (string.IsNullOrWhiteSpace(response))
{
logger.LogWarning("AI 返回空响应");
return Array.Empty<ClassificationResult>();
}
// 解析 NDJSON 格式的 AI 响应
var results = new List<ClassificationResult>();
var lines = response.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
continue;
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
var result = new ClassificationResult
{
Reason = root.GetProperty("reason").GetString() ?? string.Empty,
Classify = root.GetProperty("classify").GetString() ?? string.Empty,
Type = (TransactionType)root.GetProperty("type").GetInt32(),
Confidence = 0.9 // 可从 AI 响应中解析
};
results.Add(result);
}
catch (JsonException ex)
{
logger.LogWarning(ex, "解析 AI 响应行失败: {Line}", line);
}
}
logger.LogInformation("AI 分类完成,得到 {Count} 条结果", results.Count);
return results.ToArray();
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Agents.AI;
namespace Service.AgentFramework;
/// <summary>
/// Agent Framework 依赖注入扩展
/// </summary>
public static class AgentFrameworkExtensions
{
/// <summary>
/// 注册 Agent Framework 相关服务
/// </summary>
public static IServiceCollection AddAgentFramework(this IServiceCollection services)
{
// 注册 Tool Registry (Singleton - 无状态,全局共享)
services.AddSingleton<IToolRegistry, ToolRegistry>();
// 注册 Tools (Scoped - 因为依赖 Scoped Repository)
services.AddSingleton<ITransactionQueryTools, TransactionQueryTools>();
services.AddSingleton<ITextProcessingTools, TextProcessingTools>();
services.AddSingleton<IAITools, AITools>();
// 注册 Agents (Scoped - 因为依赖 Scoped Tools)
services.AddSingleton<ClassificationAgent>();
services.AddSingleton<ParsingAgent>();
services.AddSingleton<ImportAgent>();
// 注册 Service Facade (Scoped - 避免生命周期冲突)
services.AddSingleton<ISmartHandleServiceV2, SmartHandleServiceV2>();
return services;
}
/// <summary>
/// 初始化 Agent 框架的 Tools
/// 在应用启动时调用此方法
/// </summary>
public static void InitializeAgentTools(
this IServiceProvider serviceProvider)
{
var toolRegistry = serviceProvider.GetRequiredService<IToolRegistry>();
var logger = serviceProvider.GetRequiredService<ILogger<IToolRegistry>>();
logger.LogInformation("开始初始化 Agent Tools...");
// 这里可以注册更多的 Tool
// 目前大部分 Tool 被整合到了工具类中,后续可根据需要扩展
logger.LogInformation("Agent Tools 初始化完成");
}
}

View File

@@ -0,0 +1,141 @@
namespace Service.AgentFramework;
/// <summary>
/// Agent 执行结果的标准化输出模型
/// </summary>
/// <typeparam name="T">数据类型</typeparam>
public record AgentResult<T>
{
/// <summary>
/// Agent 执行的主要数据结果
/// </summary>
public T Data { get; init; } = default!;
/// <summary>
/// 多轮提炼后的总结信息3-5 句,包含关键指标)
/// </summary>
public string Summary { get; init; } = string.Empty;
/// <summary>
/// Agent 执行的步骤链(用于可视化和调试)
/// </summary>
public List<ExecutionStep> Steps { get; init; } = new();
/// <summary>
/// 元数据(统计信息、性能指标等)
/// </summary>
public Dictionary<string, object?> Metadata { get; init; } = new();
/// <summary>
/// 执行是否成功
/// </summary>
public bool Success { get; init; } = true;
/// <summary>
/// 错误信息(如果有的话)
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Agent 执行步骤
/// </summary>
public record ExecutionStep
{
/// <summary>
/// 步骤名称
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 步骤描述
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// 步骤状态Pending, Running, Completed, Failed
/// </summary>
public string Status { get; init; } = "Pending";
/// <summary>
/// 执行耗时(毫秒)
/// </summary>
public long DurationMs { get; init; }
/// <summary>
/// 步骤输出数据(可选)
/// </summary>
public object? Output { get; init; }
/// <summary>
/// 错误信息(如果步骤失败)
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// 分类结果模型
/// </summary>
public record ClassificationResult
{
/// <summary>
/// 原始摘要
/// </summary>
public string Reason { get; init; } = string.Empty;
/// <summary>
/// 分类名称
/// </summary>
public string Classify { get; init; } = string.Empty;
/// <summary>
/// 交易类型
/// </summary>
public TransactionType Type { get; init; }
/// <summary>
/// AI 置信度评分 (0-1)
/// </summary>
public double Confidence { get; init; }
/// <summary>
/// 影响的交易记录 ID
/// </summary>
public List<long> TransactionIds { get; init; } = new();
/// <summary>
/// 参考的相似记录
/// </summary>
public List<string> References { get; init; } = new();
}
/// <summary>
/// 账单解析结果模型
/// </summary>
public record TransactionParseResult
{
/// <summary>
/// 金额
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 摘要
/// </summary>
public string Reason { get; init; } = string.Empty;
/// <summary>
/// 日期
/// </summary>
public DateTime Date { get; init; }
/// <summary>
/// 交易类型
/// </summary>
public TransactionType Type { get; init; }
/// <summary>
/// 分类
/// </summary>
public string? Classify { get; init; }
}

View File

@@ -0,0 +1,217 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace Service.AgentFramework;
/// <summary>
/// Agent 基类 - 提供通用的工作流编排能力
/// </summary>
public abstract class BaseAgent
{
protected readonly IToolRegistry _toolRegistry;
protected readonly ILogger<BaseAgent> _logger;
protected readonly List<ExecutionStep> _steps = new();
protected readonly Dictionary<string, object?> _metadata = new();
// 定义 ActivitySource 供 DevUI 捕获
private static readonly ActivitySource _activitySource = new("Microsoft.Agents.Workflows");
protected BaseAgent(
IToolRegistry toolRegistry,
ILogger<BaseAgent> logger)
{
_toolRegistry = toolRegistry;
_logger = logger;
}
/// <summary>
/// 记录执行步骤
/// </summary>
protected void RecordStep(
string name,
string description,
object? output = null,
long durationMs = 0)
{
var step = new ExecutionStep
{
Name = name,
Description = description,
Status = "Completed",
Output = output,
DurationMs = durationMs
};
_steps.Add(step);
// 使用 Activity 进行埋点,将被 DevUI 自动捕获
using var activity = _activitySource.StartActivity(name);
activity?.SetTag("agent.step.description", description);
if (output != null) activity?.SetTag("agent.step.output", output.ToString());
}
/// <summary>
/// 记录失败的步骤
/// </summary>
protected void RecordFailedStep(
string name,
string description,
string error,
long durationMs = 0)
{
var step = new ExecutionStep
{
Name = name,
Description = description,
Status = "Failed",
Error = error,
DurationMs = durationMs
};
_steps.Add(step);
using var activity = _activitySource.StartActivity($"{name} (Failed)");
activity?.SetTag("agent.step.error", error);
_logger.LogError("[Agent步骤失败] {StepName}: {Error}", name, error);
}
/// <summary>
/// 设置元数据
/// </summary>
protected void SetMetadata(string key, object? value)
{
_metadata[key] = value;
}
/// <summary>
/// 获取执行日志
/// </summary>
protected List<ExecutionStep> GetExecutionLog()
{
return _steps.ToList();
}
/// <summary>
/// 生成多轮总结
/// </summary>
protected virtual async Task<string> GenerateSummaryAsync(
string[] phases,
Dictionary<string, object?> phaseResults)
{
var summaryParts = new List<string>();
// 简单的总结生成逻辑
// 实际项目中可以集成 AI 生成更复杂的总结
foreach (var phase in phases)
{
if (phaseResults.TryGetValue(phase, out var result))
{
summaryParts.Add($"{phase}:已完成");
}
}
return await Task.FromResult(string.Join("", summaryParts) + "。");
}
/// <summary>
/// 调用 Tool简化接口
/// </summary>
protected async Task<TResult> CallToolAsync<TResult>(
string toolName,
string stepName,
string stepDescription)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
_logger.LogInformation("开始执行 Tool: {ToolName}", toolName);
var result = await _toolRegistry.InvokeToolAsync<TResult>(toolName);
sw.Stop();
RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
sw.Stop();
RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds);
throw;
}
}
/// <summary>
/// 调用带参数的 Tool
/// </summary>
protected async Task<TResult> CallToolAsync<TParam, TResult>(
string toolName,
TParam param,
string stepName,
string stepDescription)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
_logger.LogInformation("开始执行 Tool: {ToolName},参数: {Param}", toolName, param);
var result = await _toolRegistry.InvokeToolAsync<TParam, TResult>(toolName, param);
sw.Stop();
RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
sw.Stop();
RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds);
throw;
}
}
/// <summary>
/// 调用带多参数的 Tool
/// </summary>
protected async Task<TResult> CallToolAsync<TParam1, TParam2, TResult>(
string toolName,
TParam1 param1,
TParam2 param2,
string stepName,
string stepDescription)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
_logger.LogInformation("开始执行 Tool: {ToolName},参数: {Param1}, {Param2}", toolName, param1, param2);
var result = await _toolRegistry.InvokeToolAsync<TParam1, TParam2, TResult>(
toolName, param1, param2);
sw.Stop();
RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
sw.Stop();
RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds);
throw;
}
}
/// <summary>
/// 获取 Agent 执行结果
/// </summary>
protected AgentResult<T> CreateResult<T>(
T data,
string summary,
bool success = true,
string? error = null)
{
return new AgentResult<T>
{
Data = data,
Summary = summary,
Steps = _steps,
Metadata = _metadata,
Success = success,
Error = error
};
}
}

View File

@@ -0,0 +1,301 @@
namespace Service.AgentFramework;
/// <summary>
/// 账单分类 Agent - 负责智能分类流程编排
/// </summary>
public class ClassificationAgent : BaseAgent
{
private readonly ITransactionQueryTools _queryTools;
private readonly ITextProcessingTools _textTools;
private readonly IAITools _aiTools;
private readonly Action<(string type, string data)>? _progressCallback;
public ClassificationAgent(
IToolRegistry toolRegistry,
ITransactionQueryTools queryTools,
ITextProcessingTools textTools,
IAITools aiTools,
ILogger<ClassificationAgent> logger,
Action<(string type, string data)>? progressCallback = null
) : base(toolRegistry, logger)
{
_queryTools = queryTools;
_textTools = textTools;
_aiTools = aiTools;
_progressCallback = progressCallback;
}
/// <summary>
/// 执行智能分类工作流
/// </summary>
public async Task<AgentResult<ClassificationResult[]>> ExecuteAsync(
long[] transactionIds,
ITransactionCategoryRepository categoryRepository)
{
try
{
// ========== Phase 1: 数据采集阶段 ==========
ReportProgress("start", "开始分类,正在查询待分类账单");
var sampleRecords = await _queryTools.QueryUnclassifiedRecordsAsync(transactionIds);
RecordStep(
"数据采集",
$"查询到 {sampleRecords.Length} 条待分类账单",
sampleRecords.Length);
if (sampleRecords.Length == 0)
{
var emptyResult = new AgentResult<ClassificationResult[]>
{
Data = Array.Empty<ClassificationResult>(),
Summary = "未找到待分类的账单。",
Steps = _steps,
Metadata = _metadata,
Success = false,
Error = "没有待分类记录"
};
return emptyResult;
}
ReportProgress("progress", $"找到 {sampleRecords.Length} 条待分类账单");
SetMetadata("sample_count", sampleRecords.Length);
// ========== Phase 2: 分析阶段 ==========
ReportProgress("progress", "正在进行分析...");
// 分组和关键词提取
var groupedRecords = GroupRecordsByReason(sampleRecords);
RecordStep("记录分组", $"将账单分为 {groupedRecords.Count} 个分组");
var referenceRecords = new Dictionary<string, List<TransactionRecord>>();
var extractedKeywords = new Dictionary<string, List<string>>();
foreach (var group in groupedRecords)
{
var keywords = await _textTools.ExtractKeywordsAsync(group.Reason);
extractedKeywords[group.Reason] = keywords;
if (keywords.Count > 0)
{
var similar = await _queryTools.QueryClassifiedByKeywordsAsync(keywords, minMatchRate: 0.4, limit: 10);
if (similar.Count > 0)
{
var topSimilar = similar.Take(5).Select(x => x.record).ToList();
referenceRecords[group.Reason] = topSimilar;
}
}
}
RecordStep(
"关键词提取与相似度匹配",
$"为 {extractedKeywords.Count} 个摘要提取了关键词,找到 {referenceRecords.Count} 个参考记录",
referenceRecords.Count);
SetMetadata("groups_count", groupedRecords.Count);
SetMetadata("reference_records_count", referenceRecords.Count);
ReportProgress("progress", $"分析完成,共分组 {groupedRecords.Count} 个");
// ========== Phase 3: 决策阶段 ==========
_logger.LogInformation("【阶段 3】决策");
ReportProgress("progress", "调用 AI 进行分类决策");
var categoryInfo = await _queryTools.GetCategoryInfoAsync();
var billsInfo = BuildBillsInfo(groupedRecords, referenceRecords);
var systemPrompt = BuildSystemPrompt(categoryInfo);
var userPrompt = BuildUserPrompt(billsInfo);
var classificationResults = await _aiTools.ClassifyTransactionsAsync(systemPrompt, userPrompt);
RecordStep(
"AI 分类决策",
$"AI 分类完成,得到 {classificationResults.Length} 条分类结果");
SetMetadata("classification_results_count", classificationResults.Length);
// ========== Phase 4: 结果保存阶段 ==========
_logger.LogInformation("【阶段 4】保存结果");
ReportProgress("progress", "正在保存分类结果...");
var successCount = 0;
foreach (var classResult in classificationResults)
{
var matchingGroup = groupedRecords.FirstOrDefault(g => g.Reason == classResult.Reason);
if (matchingGroup.Reason == null)
continue;
foreach (var id in matchingGroup.Ids)
{
var success = await _queryTools.UpdateTransactionClassifyAsync(
id,
classResult.Classify,
classResult.Type);
if (success)
{
successCount++;
var resultJson = JsonSerializer.Serialize(new
{
id,
classResult.Classify,
classResult.Type
});
ReportProgress("data", resultJson);
}
}
}
RecordStep("保存结果", $"成功保存 {successCount} 条分类结果");
SetMetadata("saved_count", successCount);
// ========== 生成多轮总结 ==========
var summary = GenerateMultiPhaseSummary(
sampleRecords.Length,
groupedRecords.Count,
classificationResults.Length,
successCount);
var finalResult = new AgentResult<ClassificationResult[]>
{
Data = classificationResults,
Summary = summary,
Steps = _steps,
Metadata = _metadata,
Success = true
};
ReportProgress("success", $"分类完成!{summary}");
_logger.LogInformation("=== 分类 Agent 执行完成 ===");
return finalResult;
}
catch (Exception ex)
{
_logger.LogError(ex, "分类 Agent 执行失败");
var errorResult = new AgentResult<ClassificationResult[]>
{
Data = Array.Empty<ClassificationResult>(),
Summary = $"分类失败: {ex.Message}",
Steps = _steps,
Metadata = _metadata,
Success = false,
Error = ex.Message
};
ReportProgress("error", ex.Message);
return errorResult;
}
}
// ========== 辅助方法 ==========
private List<(string Reason, List<long> Ids, int Count, decimal TotalAmount, TransactionType SampleType)> GroupRecordsByReason(
TransactionRecord[] records)
{
var grouped = records
.GroupBy(r => r.Reason)
.Select(g => (
Reason: g.Key,
Ids: g.Select(r => r.Id).ToList(),
Count: g.Count(),
TotalAmount: g.Sum(r => r.Amount),
SampleType: g.First().Type
))
.OrderByDescending(g => Math.Abs(g.TotalAmount))
.ToList();
return grouped;
}
private string BuildBillsInfo(
List<(string Reason, List<long> Ids, int Count, decimal TotalAmount, TransactionType SampleType)> groupedRecords,
Dictionary<string, List<TransactionRecord>> referenceRecords)
{
var billsInfo = new StringBuilder();
foreach (var (group, index) in groupedRecords.Select((g, i) => (g, i)))
{
billsInfo.AppendLine($"{index + 1}. 摘要={group.Reason}, 当前类型={GetTypeName(group.SampleType)}, 涉及金额={group.TotalAmount}");
if (referenceRecords.TryGetValue(group.Reason, out var references))
{
billsInfo.AppendLine(" 【参考】相似且已分类的账单:");
foreach (var refer in references.Take(3))
{
billsInfo.AppendLine($" - 摘要={refer.Reason}, 分类={refer.Classify}, 类型={GetTypeName(refer.Type)}, 金额={refer.Amount}");
}
}
}
return billsInfo.ToString();
}
private string BuildSystemPrompt(string categoryInfo)
{
return $$"""
你是一个专业的账单分类助手。请根据提供的账单分组信息和分类列表,为每个分组选择最合适的分类。
可用的分类列表:
{{categoryInfo}}
分类规则:
1. 根据账单的摘要和涉及金额,选择最匹配的分类
2. 如果提供了【参考】信息,优先参考相似账单的分类,这些是历史上已分类的相似账单
3. 如果无法确定分类,可以选择""
4.
- 使 NDJSON JSON
- JSON格式严格为
{
"reason": "交易摘要",
"type": Number, // 交易类型0=支出1=收入2=不计入收支
"classify": "分类名称"
}
-
- JSON
JSON NDJSON
""";
}
private string BuildUserPrompt(string billsInfo)
{
return $$"""
请为以下账单分组进行分类:
{{billsInfo}}
请逐个输出分类结果。
""";
}
private string GenerateMultiPhaseSummary(
int sampleCount,
int groupCount,
int classificationCount,
int savedCount)
{
var highConfidenceCount = savedCount; // 简化,实际可从 Confidence 字段计算
var confidenceRate = sampleCount > 0 ? (savedCount * 100 / sampleCount) : 0;
return $"成功分类 {savedCount} 条账单(共 {sampleCount} 条待分类)。" +
$"分为 {groupCount} 个分组AI 给出 {classificationCount} 条分类建议。" +
$"分类完成度 {confidenceRate}%,所有结果已保存。";
}
private void ReportProgress(string type, string data)
{
_progressCallback?.Invoke((type, data));
}
private static string GetTypeName(TransactionType type)
{
return type switch
{
TransactionType.Expense => "支出",
TransactionType.Income => "收入",
TransactionType.None => "不计入",
_ => "未知"
};
}
}

View File

@@ -0,0 +1,101 @@
namespace Service.AgentFramework;
/// <summary>
/// Tool 的定义和元数据
/// </summary>
public record ToolDefinition
{
/// <summary>
/// Tool 唯一标识
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Tool 描述
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// Tool 对应的委托
/// </summary>
public Delegate Handler { get; init; } = null!;
/// <summary>
/// Tool 所属类别
/// </summary>
public string Category { get; init; } = string.Empty;
/// <summary>
/// Tool 是否可缓存
/// </summary>
public bool Cacheable { get; init; }
}
/// <summary>
/// Tool Registry 接口 - 管理所有可用的 Tools
/// </summary>
public interface IToolRegistry
{
/// <summary>
/// 注册一个 Tool
/// </summary>
void RegisterTool<TResult>(
string name,
string description,
Func<Task<TResult>> handler,
string category = "General",
bool cacheable = false);
/// <summary>
/// 注册一个带参数的 Tool
/// </summary>
void RegisterTool<TParam, TResult>(
string name,
string description,
Func<TParam, Task<TResult>> handler,
string category = "General",
bool cacheable = false);
/// <summary>
/// 注册一个带多参数的 Tool
/// </summary>
void RegisterTool<TParam1, TParam2, TResult>(
string name,
string description,
Func<TParam1, TParam2, Task<TResult>> handler,
string category = "General",
bool cacheable = false);
/// <summary>
/// 获取 Tool 定义
/// </summary>
ToolDefinition? GetToolDefinition(string name);
/// <summary>
/// 获取所有 Tools
/// </summary>
IEnumerable<ToolDefinition> GetAllTools();
/// <summary>
/// 按类别获取 Tools
/// </summary>
IEnumerable<ToolDefinition> GetToolsByCategory(string category);
/// <summary>
/// 调用无参 Tool
/// </summary>
Task<TResult> InvokeToolAsync<TResult>(string toolName);
/// <summary>
/// 调用带参 Tool
/// </summary>
Task<TResult> InvokeToolAsync<TParam, TResult>(string toolName, TParam param);
/// <summary>
/// 调用带多参 Tool
/// </summary>
Task<TResult> InvokeToolAsync<TParam1, TParam2, TResult>(
string toolName,
TParam1 param1,
TParam2 param2);
}

View File

@@ -0,0 +1,190 @@
namespace Service.AgentFramework;
/// <summary>
/// 文件导入 Agent - 处理支付宝、微信等账单导入
/// </summary>
public class ImportAgent : BaseAgent
{
private readonly ITransactionQueryTools _queryTools;
private readonly ILogger<ImportAgent> _importLogger;
public ImportAgent(
IToolRegistry toolRegistry,
ITransactionQueryTools queryTools,
ILogger<ImportAgent> logger,
ILogger<ImportAgent> importLogger
) : base(toolRegistry, logger)
{
_queryTools = queryTools;
_importLogger = importLogger;
}
/// <summary>
/// 执行批量导入流程
/// </summary>
public async Task<AgentResult<ImportResult>> ExecuteAsync(
Dictionary<string, string>[] rows,
string source,
Func<Dictionary<string, string>, Task<TransactionRecord?>> transformAsync)
{
try
{
// Phase 1: 数据验证
RecordStep("数据验证", $"验证 {rows.Length} 条记录");
SetMetadata("total_rows", rows.Length);
var importNos = rows
.Select(r => r.ContainsKey("交易号") ? r["交易号"] : null)
.Where(no => !string.IsNullOrWhiteSpace(no))
.Cast<string>()
.ToArray();
if (importNos.Length == 0)
{
var emptyResult = new ImportResult
{
TotalCount = rows.Length,
AddedCount = 0,
UpdatedCount = 0,
SkippedCount = rows.Length
};
return CreateResult(
emptyResult,
"导入失败:找不到有效的交易号。",
false,
"No valid transaction numbers found");
}
// Phase 2: 批量检查存在性
_logger.LogInformation("【阶段 2】批量检查存在性");
var existenceMap = await _queryTools.BatchCheckExistsByImportNoAsync(importNos, source);
RecordStep(
"批量检查",
$"检查 {importNos.Length} 条记录,其中 {existenceMap.Values.Count(v => v)} 条已存在");
SetMetadata("existing_count", existenceMap.Values.Count(v => v));
SetMetadata("new_count", existenceMap.Values.Count(v => !v));
// Phase 3: 数据转换和冲突解决
_logger.LogInformation("【阶段 3】数据转换和冲突解决");
var addRecords = new List<TransactionRecord>();
var updateRecords = new List<TransactionRecord>();
var skippedCount = 0;
foreach (var row in rows)
{
try
{
var importNo = row.ContainsKey("交易号") ? row["交易号"] : null;
if (string.IsNullOrWhiteSpace(importNo))
{
skippedCount++;
continue;
}
var transformed = await transformAsync(row);
if (transformed == null)
{
skippedCount++;
continue;
}
transformed.ImportNo = importNo;
transformed.ImportFrom = source;
var exists = existenceMap.GetValueOrDefault(importNo, false);
if (exists)
{
updateRecords.Add(transformed);
}
else
{
addRecords.Add(transformed);
}
}
catch (Exception ex)
{
_importLogger.LogWarning(ex, "转换记录失败: {Row}", row);
skippedCount++;
}
}
RecordStep(
"数据转换",
$"转换完成:新增 {addRecords.Count},更新 {updateRecords.Count},跳过 {skippedCount}");
SetMetadata("add_count", addRecords.Count);
SetMetadata("update_count", updateRecords.Count);
SetMetadata("skip_count", skippedCount);
// Phase 4: 批量保存
_logger.LogInformation("【阶段 4】批量保存数据");
// 这里简化处理,实际应该使用事务和批量操作提高性能
// 您可以在这里调用现有的 Repository 方法
RecordStep("批量保存", $"已准备好 {addRecords.Count + updateRecords.Count} 条待保存记录");
var importResult = new ImportResult
{
TotalCount = rows.Length,
AddedCount = addRecords.Count,
UpdatedCount = updateRecords.Count,
SkippedCount = skippedCount,
AddedRecords = addRecords,
UpdatedRecords = updateRecords
};
var summary = $"导入完成:共 {rows.Length} 条记录,新增 {addRecords.Count},更新 {updateRecords.Count},跳过 {skippedCount}。";
_logger.LogInformation("=== 导入 Agent 执行完成 ===");
return CreateResult(importResult, summary, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "导入 Agent 执行失败");
return CreateResult(
new ImportResult { TotalCount = rows.Length },
$"导入失败: {ex.Message}",
false,
ex.Message);
}
}
}
/// <summary>
/// 导入结果
/// </summary>
public record ImportResult
{
/// <summary>
/// 总记录数
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 新增数
/// </summary>
public int AddedCount { get; init; }
/// <summary>
/// 更新数
/// </summary>
public int UpdatedCount { get; init; }
/// <summary>
/// 跳过数
/// </summary>
public int SkippedCount { get; init; }
/// <summary>
/// 新增的记录(可选)
/// </summary>
public List<TransactionRecord> AddedRecords { get; init; } = new();
/// <summary>
/// 更新的记录(可选)
/// </summary>
public List<TransactionRecord> UpdatedRecords { get; init; } = new();
}

View File

@@ -0,0 +1,62 @@
namespace Service.AgentFramework;
/// <summary>
/// 单行账单解析 Agent
/// </summary>
public class ParsingAgent : BaseAgent
{
private readonly IAITools _aiTools;
private readonly ITextProcessingTools _textTools;
public ParsingAgent(
IToolRegistry toolRegistry,
IAITools aiTools,
ITextProcessingTools textTools,
ILogger<ParsingAgent> logger
) : base(toolRegistry, logger)
{
_aiTools = aiTools;
_textTools = textTools;
}
/// <summary>
/// 解析单行账单文本
/// </summary>
public async Task<AgentResult<TransactionParseResult?>> ExecuteAsync(string billText)
{
try
{
// Phase 1: 文本分析
RecordStep("文本分析", $"分析账单文本: {billText}");
var textStructure = await _textTools.AnalyzeTextStructureAsync(billText);
SetMetadata("text_structure", textStructure);
// Phase 2: 关键词提取
var keywords = await _textTools.ExtractKeywordsAsync(billText);
RecordStep("关键词提取", $"提取到 {keywords.Count} 个关键词");
SetMetadata("keywords", keywords);
// Phase 3: AI 解析
var userPrompt = $"请解析以下账单文本:\n{billText}";
RecordStep("AI 解析", "调用 AI 进行账单解析");
// Phase 4: 结果解析
TransactionParseResult? parseResult = null;
var summary = parseResult != null
? $"成功解析账单:{parseResult.Reason},金额 {parseResult.Amount},日期 {parseResult.Date:yyyy-MM-dd}。"
: "账单解析失败,无法提取结构化数据。";
return CreateResult<TransactionParseResult?>(parseResult, summary, parseResult != null);
}
catch (Exception ex)
{
_logger.LogError(ex, "解析 Agent 执行失败");
return CreateResult<TransactionParseResult?>(
null,
$"解析失败: {ex.Message}",
false,
ex.Message);
}
}
}

View File

@@ -0,0 +1,51 @@
namespace Service.AgentFramework;
/// <summary>
/// 文本处理工具集
/// </summary>
public interface ITextProcessingTools
{
/// <summary>
/// 提取关键词
/// </summary>
Task<List<string>> ExtractKeywordsAsync(string text);
/// <summary>
/// 分析文本结构
/// </summary>
Task<Dictionary<string, object?>> AnalyzeTextStructureAsync(string text);
}
/// <summary>
/// 文本处理工具实现
/// </summary>
public class TextProcessingTools(
ITextSegmentService textSegmentService,
ILogger<TextProcessingTools> logger
) : ITextProcessingTools
{
public async Task<List<string>> ExtractKeywordsAsync(string text)
{
logger.LogDebug("提取关键词: {Text}", text);
var keywords = await Task.FromResult(textSegmentService.ExtractKeywords(text));
logger.LogDebug("提取到 {Count} 个关键词: {Keywords}",
keywords.Count,
string.Join(", ", keywords));
return keywords;
}
public async Task<Dictionary<string, object?>> AnalyzeTextStructureAsync(string text)
{
logger.LogDebug("分析文本结构");
return await Task.FromResult(new Dictionary<string, object?>
{
["length"] = text.Length,
["wordCount"] = text.Split(' ').Length,
["timestamp"] = DateTime.UtcNow
});
}
}

View File

@@ -0,0 +1,177 @@
namespace Service.AgentFramework;
/// <summary>
/// Tool 注册表实现
/// </summary>
public class ToolRegistry : IToolRegistry
{
private readonly Dictionary<string, ToolDefinition> _tools = new();
private readonly ILogger<ToolRegistry> _logger;
public ToolRegistry(ILogger<ToolRegistry> logger)
{
_logger = logger;
}
public void RegisterTool<TResult>(
string name,
string description,
Func<Task<TResult>> handler,
string category = "General",
bool cacheable = false)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Tool 名称不能为空", nameof(name));
var toolDef = new ToolDefinition
{
Name = name,
Description = description,
Handler = handler,
Category = category,
Cacheable = cacheable
};
_tools[name] = toolDef;
_logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category);
}
public void RegisterTool<TParam, TResult>(
string name,
string description,
Func<TParam, Task<TResult>> handler,
string category = "General",
bool cacheable = false)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Tool 名称不能为空", nameof(name));
var toolDef = new ToolDefinition
{
Name = name,
Description = description,
Handler = handler,
Category = category,
Cacheable = cacheable
};
_tools[name] = toolDef;
_logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category);
}
public void RegisterTool<TParam1, TParam2, TResult>(
string name,
string description,
Func<TParam1, TParam2, Task<TResult>> handler,
string category = "General",
bool cacheable = false)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Tool 名称不能为空", nameof(name));
var toolDef = new ToolDefinition
{
Name = name,
Description = description,
Handler = handler,
Category = category,
Cacheable = cacheable
};
_tools[name] = toolDef;
_logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category);
}
public ToolDefinition? GetToolDefinition(string name)
{
return _tools.TryGetValue(name, out var tool) ? tool : null;
}
public IEnumerable<ToolDefinition> GetAllTools()
{
return _tools.Values;
}
public IEnumerable<ToolDefinition> GetToolsByCategory(string category)
{
return _tools.Values.Where(t => t.Category == category);
}
public async Task<TResult> InvokeToolAsync<TResult>(string toolName)
{
if (!_tools.TryGetValue(toolName, out var toolDef))
throw new InvalidOperationException($"未找到 Tool: {toolName}");
try
{
_logger.LogDebug("调用 Tool: {ToolName}", toolName);
if (toolDef.Handler is Func<Task<TResult>> handler)
{
var result = await handler();
_logger.LogDebug("Tool {ToolName} 执行成功", toolName);
return result;
}
throw new InvalidOperationException($"Tool {toolName} 签名不匹配");
}
catch (Exception ex)
{
_logger.LogError(ex, "Tool {ToolName} 执行失败", toolName);
throw;
}
}
public async Task<TResult> InvokeToolAsync<TParam, TResult>(string toolName, TParam param)
{
if (!_tools.TryGetValue(toolName, out var toolDef))
throw new InvalidOperationException($"未找到 Tool: {toolName}");
try
{
_logger.LogDebug("调用 Tool: {ToolName}, 参数: {Param}", toolName, param);
if (toolDef.Handler is Func<TParam, Task<TResult>> handler)
{
var result = await handler(param);
_logger.LogDebug("Tool {ToolName} 执行成功", toolName);
return result;
}
throw new InvalidOperationException($"Tool {toolName} 签名不匹配");
}
catch (Exception ex)
{
_logger.LogError(ex, "Tool {ToolName} 执行失败", toolName);
throw;
}
}
public async Task<TResult> InvokeToolAsync<TParam1, TParam2, TResult>(
string toolName,
TParam1 param1,
TParam2 param2)
{
if (!_tools.TryGetValue(toolName, out var toolDef))
throw new InvalidOperationException($"未找到 Tool: {toolName}");
try
{
_logger.LogDebug("调用 Tool: {ToolName}, 参数: {Param1}, {Param2}", toolName, param1, param2);
if (toolDef.Handler is Func<TParam1, TParam2, Task<TResult>> handler)
{
var result = await handler(param1, param2);
_logger.LogDebug("Tool {ToolName} 执行成功", toolName);
return result;
}
throw new InvalidOperationException($"Tool {toolName} 签名不匹配");
}
catch (Exception ex)
{
_logger.LogError(ex, "Tool {ToolName} 执行失败", toolName);
throw;
}
}
}

View File

@@ -0,0 +1,150 @@
namespace Service.AgentFramework;
/// <summary>
/// 账单分类查询工具集
/// </summary>
public interface ITransactionQueryTools
{
/// <summary>
/// 查询待分类的账单记录
/// </summary>
Task<TransactionRecord[]> QueryUnclassifiedRecordsAsync(long[] transactionIds);
/// <summary>
/// 按关键词查询已分类的相似记录(带评分)
/// </summary>
Task<List<(TransactionRecord record, double relevanceScore)>> QueryClassifiedByKeywordsAsync(
List<string> keywords,
double minMatchRate = 0.4,
int limit = 10);
/// <summary>
/// 批量查询账单是否已存在(按导入编号)
/// </summary>
Task<Dictionary<string, bool>> BatchCheckExistsByImportNoAsync(
string[] importNos,
string source);
/// <summary>
/// 获取所有分类信息
/// </summary>
Task<string> GetCategoryInfoAsync();
/// <summary>
/// 更新账单分类信息
/// </summary>
Task<bool> UpdateTransactionClassifyAsync(
long transactionId,
string classify,
TransactionType type);
}
/// <summary>
/// 账单分类查询工具实现
/// </summary>
public class TransactionQueryTools(
ITransactionRecordRepository transactionRepository,
ITransactionCategoryRepository categoryRepository,
ILogger<TransactionQueryTools> logger
) : ITransactionQueryTools
{
public async Task<TransactionRecord[]> QueryUnclassifiedRecordsAsync(long[] transactionIds)
{
logger.LogInformation("查询待分类记录ID 数量: {Count}", transactionIds.Length);
var records = await transactionRepository.GetByIdsAsync(transactionIds);
var unclassified = records
.Where(x => string.IsNullOrEmpty(x.Classify))
.ToArray();
logger.LogInformation("找到 {Count} 条待分类记录", unclassified.Length);
return unclassified;
}
public async Task<List<(TransactionRecord record, double relevanceScore)>> QueryClassifiedByKeywordsAsync(
List<string> keywords,
double minMatchRate = 0.4,
int limit = 10)
{
logger.LogInformation("按关键词查询相似记录,关键词: {Keywords}", string.Join(", ", keywords));
var result = await transactionRepository.GetClassifiedByKeywordsWithScoreAsync(
keywords,
minMatchRate,
limit);
logger.LogInformation("找到 {Count} 条相似记录,相关度分数: {Scores}",
result.Count,
string.Join(", ", result.Select(x => $"{x.record.Reason}({x.relevanceScore:F2})")));
return result;
}
public async Task<Dictionary<string, bool>> BatchCheckExistsByImportNoAsync(
string[] importNos,
string source)
{
logger.LogInformation("批量检查导入编号是否存在,数量: {Count},来源: {Source}",
importNos.Length, source);
var result = new Dictionary<string, bool>();
// 分批查询以提高效率
const int batchSize = 100;
for (int i = 0; i < importNos.Length; i += batchSize)
{
var batch = importNos.Skip(i).Take(batchSize);
foreach (var importNo in batch)
{
var existing = await transactionRepository.ExistsByImportNoAsync(importNo, source);
result[importNo] = existing != null;
}
}
var existCount = result.Values.Count(v => v);
logger.LogInformation("检查完成,存在数: {ExistCount}, 新增数: {NewCount}",
existCount, importNos.Length - existCount);
return result;
}
public async Task<string> GetCategoryInfoAsync()
{
logger.LogInformation("获取分类信息");
var categories = await categoryRepository.GetAllAsync();
var sb = new StringBuilder();
sb.AppendLine("可用分类列表:");
foreach (var cat in categories)
{
sb.AppendLine($"- {cat.Name}");
}
return sb.ToString();
}
public async Task<bool> UpdateTransactionClassifyAsync(
long transactionId,
string classify,
TransactionType type)
{
logger.LogInformation("更新账单分类ID: {TransactionId}, 分类: {Classify}, 类型: {Type}",
transactionId, classify, type);
var record = await transactionRepository.GetByIdAsync(transactionId);
if (record == null)
{
logger.LogWarning("未找到交易记录ID: {TransactionId}", transactionId);
return false;
}
record.Classify = classify;
record.Type = type;
var result = await transactionRepository.UpdateAsync(record);
logger.LogInformation("账单分类更新结果: {Success}", result);
return result;
}
}

View File

@@ -0,0 +1,8 @@
namespace Service.AppSettingModel;
public class NotificationSettings
{
public string Subject { get; set; } = string.Empty;
public string PublicKey { get; set; } = string.Empty;
public string PrivateKey { get; set; } = string.Empty;
}

728
Service/BudgetService.cs Normal file
View File

@@ -0,0 +1,728 @@
namespace Service;
public interface IBudgetService
{
Task<List<BudgetResult>> GetListAsync(DateTime? referenceDate = null);
Task<BudgetResult?> GetStatisticsAsync(long id, DateTime referenceDate);
Task<string> ArchiveBudgetsAsync(int year, int month);
/// <summary>
/// 获取指定分类的统计信息(月度和年度)
/// </summary>
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime? referenceDate = null);
/// <summary>
/// 获取未被预算覆盖的分类统计信息
/// </summary>
Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
}
public class BudgetService(
IBudgetRepository budgetRepository,
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionRecordRepository transactionRecordRepository,
IOpenAiService openAiService,
IConfigService configService,
IMessageService messageService,
ILogger<BudgetService> logger
) : IBudgetService
{
public async Task<List<BudgetResult>> GetListAsync(DateTime? referenceDate = null)
{
var budgets = await budgetRepository.GetAllAsync();
var dtos = new List<BudgetResult?>();
foreach (var budget in budgets)
{
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
dtos.Add(BudgetResult.FromEntity(budget, currentAmount, referenceDate));
}
// 创造虚拟的存款预算
dtos.Add(await GetVirtualSavingsDtoAsync(
BudgetPeriodType.Month,
referenceDate,
budgets));
dtos.Add(await GetVirtualSavingsDtoAsync(
BudgetPeriodType.Year,
referenceDate,
budgets));
return dtos.Where(dto => dto != null).Cast<BudgetResult>().ToList();
}
public async Task<BudgetResult?> GetStatisticsAsync(long id, DateTime referenceDate)
{
bool isArchive = false;
BudgetRecord? budget = null;
if (id == -1)
{
if (isAcrhiveFunc(BudgetPeriodType.Year))
{
isArchive = true;
budget = await BuildVirtualSavingsBudgetRecordAsync(-1, referenceDate, 0);
}
}
else if (id == -2)
{
if (isAcrhiveFunc(BudgetPeriodType.Month))
{
isArchive = true;
budget = await BuildVirtualSavingsBudgetRecordAsync(-2, referenceDate, 0);
}
}
else
{
budget = await budgetRepository.GetByIdAsync(id);
if (budget == null)
{
return null;
}
isArchive = isAcrhiveFunc(budget.Type);
}
if (isArchive && budget != null)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(
id,
referenceDate.Year,
referenceDate.Month);
if (archive != null) // 存在归档 直接读取归档数据
{
budget.Limit = archive.BudgetedAmount;
return BudgetResult.FromEntity(
budget,
archive.RealizedAmount,
referenceDate,
archive.Description ?? string.Empty);
}
}
if (id == -1)
{
return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Year, referenceDate);
}
if (id == -2)
{
return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Month, referenceDate);
}
budget = await budgetRepository.GetByIdAsync(id);
if (budget == null)
{
return null;
}
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
return BudgetResult.FromEntity(budget, currentAmount, referenceDate);
bool isAcrhiveFunc(BudgetPeriodType periodType)
{
if (periodType == BudgetPeriodType.Year)
{
return DateTime.Now.Year > referenceDate.Year;
}
else if (periodType == BudgetPeriodType.Month)
{
return DateTime.Now.Year > referenceDate.Year
|| (DateTime.Now.Year == referenceDate.Year
&& DateTime.Now.Month > referenceDate.Month);
}
return false;
}
}
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime? referenceDate = null)
{
var budgets = (await budgetRepository.GetAllAsync()).ToList();
var refDate = referenceDate ?? DateTime.Now;
var result = new BudgetCategoryStats();
// 获取月度统计
result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, refDate);
// 获取年度统计
result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, refDate);
return result;
}
public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null)
{
var date = referenceDate ?? DateTime.Now;
var transactionType = category switch
{
BudgetCategory.Expense => TransactionType.Expense,
BudgetCategory.Income => TransactionType.Income,
_ => TransactionType.None
};
if (transactionType == TransactionType.None) return new List<UncoveredCategoryDetail>();
// 1. 获取所有预算
var budgets = (await budgetRepository.GetAllAsync()).ToList();
var coveredCategories = budgets
.Where(b => b.Category == category)
.SelectMany(b => b.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries))
.ToHashSet();
// 2. 获取分类统计
var stats = await transactionRecordRepository.GetCategoryStatisticsAsync(date.Year, date.Month, transactionType);
// 3. 过滤未覆盖的
return stats
.Where(s => !coveredCategories.Contains(s.Classify))
.Select(s => new UncoveredCategoryDetail
{
Category = s.Classify,
TransactionCount = s.Count,
TotalAmount = s.Amount
})
.OrderByDescending(x => x.TotalAmount)
.ToList();
}
private async Task<BudgetStatsDto> CalculateCategoryStatsAsync(
List<BudgetRecord> budgets,
BudgetCategory category,
BudgetPeriodType statType,
DateTime referenceDate)
{
var result = new BudgetStatsDto
{
PeriodType = statType,
Rate = 0,
Current = 0,
Limit = 0,
Count = 0
};
// 获取当前分类下所有预算
var relevant = budgets
.Where(b => b.Category == category)
.ToList();
if (relevant.Count == 0)
{
return result;
}
result.Count = relevant.Count;
decimal totalCurrent = 0;
decimal totalLimit = 0;
foreach (var budget in relevant)
{
// 限额折算
var itemLimit = budget.Limit;
if (statType == BudgetPeriodType.Month && budget.Type == BudgetPeriodType.Year)
{
// 月度视图下,年度预算不参与限额计算
itemLimit = 0;
}
else if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
{
// 年度视图下,月度预算折算为年度
itemLimit = budget.Limit * 12;
}
totalLimit += itemLimit;
// 当前值累加
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
if (budget.Type == statType)
{
totalCurrent += currentAmount;
}
else
{
// 如果周期不匹配
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
{
// 在年度视图下,月度预算计入其当前值(作为对年度目前的贡献)
totalCurrent += currentAmount;
}
// 月度视图下,年度预算的 current 不计入
}
}
result.Limit = totalLimit;
result.Current = totalCurrent;
result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
return result;
}
public async Task<string> ArchiveBudgetsAsync(int year, int month)
{
var referenceDate = new DateTime(year, month, 1);
var budgets = await GetListAsync(referenceDate);
var addArchives = new List<BudgetArchive>();
var updateArchives = new List<BudgetArchive>();
foreach (var budget in budgets)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(budget.Id, year, month);
if (archive != null)
{
archive.RealizedAmount = budget.Current;
archive.ArchiveDate = DateTime.Now;
archive.Description = budget.Description;
updateArchives.Add(archive);
}
else
{
archive = new BudgetArchive
{
BudgetId = budget.Id,
BudgetType = budget.Type,
Year = year,
Month = month,
BudgetedAmount = budget.Limit,
RealizedAmount = budget.Current,
Description = budget.Description,
ArchiveDate = DateTime.Now
};
addArchives.Add(archive);
}
}
if (addArchives.Count > 0)
{
if (!await budgetArchiveRepository.AddRangeAsync(addArchives))
{
return "保存预算归档失败";
}
}
if (updateArchives.Count > 0)
{
if (!await budgetArchiveRepository.UpdateRangeAsync(updateArchives))
{
return "更新预算归档失败";
}
}
_ = NotifyAsync(year, month);
return string.Empty;
}
private async Task NotifyAsync(int year, int month)
{
try
{
var archives = await budgetArchiveRepository.GetListAsync(year, month);
var budgets = await budgetRepository.GetAllAsync();
var budgetMap = budgets.ToDictionary(b => b.Id, b => b);
var archiveData = archives.Select(a =>
{
budgetMap.TryGetValue(a.BudgetId, out var br);
var name = br?.Name ?? (a.BudgetId == -1 ? "年度存款" : a.BudgetId == -2 ? "月度存款" : "未知");
return new
{
Name = name,
Type = a.BudgetType.ToString(),
Limit = a.BudgetedAmount,
Actual = a.RealizedAmount,
Category = br?.Category.ToString() ?? (a.BudgetId < 0 ? "Savings" : "Unknown")
};
}).ToList();
var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
$"""
SELECT
COUNT(*) AS TransactionCount,
SUM(ABS(Amount)) AS TotalAmount,
Type,
Classify
FROM TransactionRecord
WHERE OccurredAt >= '{year}-01-01'
AND OccurredAt < '{year + 1}-01-01'
GROUP BY Type, Classify
ORDER BY TotalAmount DESC
"""
);
var monthYear = new DateTime(year, month, 1).AddMonths(1);
var monthTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
$"""
SELECT
COUNT(*) AS TransactionCount,
SUM(ABS(Amount)) AS TotalAmount,
Type,
Classify
FROM TransactionRecord
WHERE OccurredAt >= '{year}-{month:00}-01'
AND OccurredAt < '{monthYear:yyyy-MM-dd}'
GROUP BY Type, Classify
ORDER BY TotalAmount DESC
"""
);
// 分析未被预算覆盖的分类 (仅针对支出类型 Type=0)
var budgetedCategories = budgets
.Where(b => !string.IsNullOrEmpty(b.SelectedCategories))
.SelectMany(b => b.SelectedCategories.Split(','))
.Distinct()
.ToHashSet();
var uncovered = monthTransactions
.Where(t =>
{
var dict = (IDictionary<string, object>)t;
var classify = dict["Classify"]?.ToString() ?? "";
var type = Convert.ToInt32(dict["Type"]);
return type == 0 && !budgetedCategories.Contains(classify);
})
.ToList();
logger.LogInformation("预算执行数据{JSON}", JsonSerializer.Serialize(archiveData));
logger.LogInformation("本月消费明细{JSON}", JsonSerializer.Serialize(monthTransactions));
logger.LogInformation("全年累计消费概况{JSON}", JsonSerializer.Serialize(yearTransactions));
logger.LogInformation("未被预算覆盖的分类{JSON}", JsonSerializer.Serialize(uncovered));
var dataPrompt = $"""
报告周期:{year}年{month}月
账单数据说明支出金额已取绝对值TotalAmount 为正数表示支出/收入的总量)。
1. 预算执行数据JSON
{JsonSerializer.Serialize(archiveData)}
2. 本月消费明细(按分类, JSON
{JsonSerializer.Serialize(monthTransactions)}
3. 全年累计消费概况(按分类, JSON
{JsonSerializer.Serialize(yearTransactions)}
4. 未被任何预算覆盖的支出分类JSON
{JsonSerializer.Serialize(uncovered)}
请生成一份专业且美观的预算执行分析报告,严格遵守以下要求:
【内容要求】
1. 概览:总结本月预算达成情况。
2. 预算详情:使用 HTML 表格展示预算执行明细(预算项、预算额、实际额、使用/达成率、状态)。
3. 超支/异常预警:重点分析超支项或支出异常的分类。
4. 消费透视:针对“未被预算覆盖的支出”提供分析建议。分析这些账单产生的合理性,并评估是否需要为其中的大额或频发分类建立新预算。
5. 改进建议:根据当前时间进度和预算完成进度,基于本月整体收入支出情况,给出下月预算调整或消费改进的专业化建议。
6. 语言风格:专业、清晰、简洁,适合财务报告阅读。
7.
【格式要求】
1. 使用HTML格式移动端H5页面风格
2. 生成清晰的报告标题(基于用户问题)
3. 使用表格展示统计数据table > thead/tbody > tr > th/td
4. 使用合适的HTML标签h2标题、h3小节、p段落、table表格、ul/li列表、strong强调
5. 支出金额用 <span class='expense-value'>金额</span> 包裹
6. 收入金额用 <span class='income-value'>金额</span> 包裹
7. 重要结论用 <span class='highlight'>内容</span> 高亮
【样式限制(重要)】
8. 不要包含 html、body、head 标签
9. 不要使用任何 style 属性或 <style> 标签
10. 不要设置 background、background-color、color 等样式属性
11. 不要使用 div 包裹大段内容
【系统信息】
当前时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}
预算归档周期:{year}年{month}月
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
""";
var htmlReport = await openAiService.ChatAsync(dataPrompt);
if (!string.IsNullOrEmpty(htmlReport))
{
await messageService.AddAsync(
title: $"{year}年{month}月 - 预算归档报告",
content: htmlReport,
type: MessageType.Html,
url: "/balance?tab=message");
}
}
catch (Exception ex)
{
logger.LogError(ex, "生成预算执行通知报告失败");
}
}
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
{
var referenceDate = now ?? DateTime.Now;
var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
return await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
}
internal static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate)
{
DateTime start;
DateTime end;
if (type == BudgetPeriodType.Month)
{
start = new DateTime(referenceDate.Year, referenceDate.Month, 1);
end = start.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
}
else if (type == BudgetPeriodType.Year)
{
start = new DateTime(referenceDate.Year, 1, 1);
end = new DateTime(referenceDate.Year, 12, 31).AddHours(23).AddMinutes(59).AddSeconds(59);
}
else
{
start = startDate;
end = DateTime.MaxValue;
}
return (start, end);
}
private async Task<BudgetResult?> GetVirtualSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable<BudgetRecord>? existingBudgets = null)
{
var allBudgets = existingBudgets;
if (existingBudgets == null)
{
allBudgets = await budgetRepository.GetAllAsync();
}
if (allBudgets == null)
{
return null;
}
var date = referenceDate ?? DateTime.Now;
decimal incomeLimitAtPeriod = 0;
decimal expenseLimitAtPeriod = 0;
var incomeItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
var expenseItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
foreach (var b in allBudgets)
{
if (b.Category == BudgetCategory.Savings) continue;
// 折算系数:根据当前请求的 periodType (Year 或 Month),将预算 b 的 Limit 折算过来
decimal factor = 1.0m;
if (periodType == BudgetPeriodType.Year)
{
factor = b.Type switch
{
BudgetPeriodType.Month => 12,
BudgetPeriodType.Year => 1,
_ => 0
};
}
else if (periodType == BudgetPeriodType.Month)
{
factor = b.Type switch
{
BudgetPeriodType.Month => 1,
BudgetPeriodType.Year => 0,
_ => 0
};
}
else
{
factor = 0; // 其他周期暂不计算虚拟存款
}
if (factor <= 0) continue;
var subtotal = b.Limit * factor;
if (b.Category == BudgetCategory.Income)
{
incomeLimitAtPeriod += subtotal;
incomeItems.Add((b.Name, b.Limit, factor, subtotal));
}
else if (b.Category == BudgetCategory.Expense)
{
expenseLimitAtPeriod += subtotal;
expenseItems.Add((b.Name, b.Limit, factor, subtotal));
}
}
var description = new StringBuilder();
description.Append("<h3>预算收入明细</h3>");
if (incomeItems.Count == 0) description.Append("<p>无收入预算</p>");
else
{
description.Append("<table><thead><tr><th>名称</th><th>金额</th><th>折算</th><th>合计</th></tr></thead><tbody>");
foreach (var item in incomeItems)
{
description.Append($"<tr><td>{item.Name}</td><td>{item.Limit:N0}</td><td>x{item.Factor:0.##}</td><td><span class='income-value'>{item.Total:N0}</span></td></tr>");
}
description.Append("</tbody></table>");
}
description.Append($"<p>收入合计: <span class='income-value'><strong>{incomeLimitAtPeriod:N0}</strong></span></p>");
description.Append("<h3>预算支出明细</h3>");
if (expenseItems.Count == 0) description.Append("<p>无支出预算</p>");
else
{
description.Append("<table><thead><tr><th>名称</th><th>金额</th><th>折算</th><th>合计</th></tr></thead><tbody>");
foreach (var item in expenseItems)
{
description.Append($"<tr><td>{item.Name}</td><td>{item.Limit:N0}</td><td>x{item.Factor:0.##}</td><td><span class='expense-value'>{item.Total:N0}</span></td></tr>");
}
description.Append("</tbody></table>");
}
description.Append($"<p>支出合计: <span class='expense-value'><strong>{expenseLimitAtPeriod:N0}</strong></span></p>");
description.Append("<h3>存款计划结论</h3>");
description.Append($"<p>计划存款 = 收入 <span class='income-value'>{incomeLimitAtPeriod:N0}</span> - 支出 <span class='expense-value'>{expenseLimitAtPeriod:N0}</span></p>");
description.Append($"<p>最终目标:<span class='highlight'><strong>{incomeLimitAtPeriod - expenseLimitAtPeriod:N0}</strong></span></p>");
var virtualBudget = await BuildVirtualSavingsBudgetRecordAsync(
periodType == BudgetPeriodType.Year ? -1 : -2,
date,
incomeLimitAtPeriod - expenseLimitAtPeriod);
// 计算实际发生的 收入 - 支出
var current = await CalculateCurrentAmountAsync(new BudgetRecord
{
Category = virtualBudget.Category,
Type = virtualBudget.Type,
SelectedCategories = virtualBudget.SelectedCategories,
StartDate = virtualBudget.StartDate,
}, date);
return BudgetResult.FromEntity(virtualBudget, current, date, description.ToString());
}
private async Task<BudgetRecord> BuildVirtualSavingsBudgetRecordAsync(
long id,
DateTime date,
decimal limit)
{
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
return new BudgetRecord
{
Id = id,
Name = id == -1 ? "年度存款" : "月度存款",
Category = BudgetCategory.Savings,
Type = id == -1 ? BudgetPeriodType.Year : BudgetPeriodType.Month,
Limit = limit,
StartDate = id == -1
? new DateTime(date.Year, 1, 1)
: new DateTime(date.Year, date.Month, 1),
SelectedCategories = savingsCategories
};
}
}
public record BudgetResult
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public BudgetPeriodType Type { get; set; }
public decimal Limit { get; set; }
public decimal Current { get; set; }
public BudgetCategory Category { get; set; }
public string[] SelectedCategories { get; set; } = Array.Empty<string>();
public string StartDate { get; set; } = string.Empty;
public string Period { get; set; } = string.Empty;
public DateTime? PeriodStart { get; set; }
public DateTime? PeriodEnd { get; set; }
public string Description { get; set; } = string.Empty;
public static BudgetResult FromEntity(
BudgetRecord entity,
decimal currentAmount = 0,
DateTime? referenceDate = null,
string description = "")
{
var date = referenceDate ?? DateTime.Now;
var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date);
return new BudgetResult
{
Id = entity.Id,
Name = entity.Name,
Type = entity.Type,
Limit = entity.Limit,
Current = currentAmount,
Category = entity.Category,
SelectedCategories = string.IsNullOrEmpty(entity.SelectedCategories)
? Array.Empty<string>()
: entity.SelectedCategories.Split(','),
StartDate = entity.StartDate.ToString("yyyy-MM-dd"),
Period = entity.Type switch
{
BudgetPeriodType.Year => $"{start:yy}年",
BudgetPeriodType.Month => $"{start:yy}年第{start.Month}月",
_ => $"{start:yyyy-MM-dd} ~ {end:yyyy-MM-dd}"
},
PeriodStart = start,
PeriodEnd = end,
Description = description
};
}
}
/// <summary>
/// 预算统计结果 DTO
/// </summary>
public class BudgetStatsDto
{
/// <summary>
/// 统计周期类型Month/Year
/// </summary>
public BudgetPeriodType PeriodType { get; set; }
/// <summary>
/// 使用率百分比0-100
/// </summary>
public decimal Rate { get; set; }
/// <summary>
/// 实际金额
/// </summary>
public decimal Current { get; set; }
/// <summary>
/// 目标/限额金额
/// </summary>
public decimal Limit { get; set; }
/// <summary>
/// 预算项数量
/// </summary>
public int Count { get; set; }
}
/// <summary>
/// 分类统计结果
/// </summary>
public class BudgetCategoryStats
{
/// <summary>
/// 月度统计
/// </summary>
public BudgetStatsDto Month { get; set; } = new();
/// <summary>
/// 年度统计
/// </summary>
public BudgetStatsDto Year { get; set; } = new();
}
public class UncoveredCategoryDetail
{
public string Category { get; set; } = string.Empty;
public int TransactionCount { get; set; }
public decimal TotalAmount { get; set; }
}

77
Service/ConfigService.cs Normal file
View File

@@ -0,0 +1,77 @@
namespace Service;
public interface IConfigService
{
/// <summary>
/// 根据Key获取配置值
/// </summary>
Task<T?> GetConfigByKeyAsync<T>(string key);
/// <summary>
/// 设置配置值
/// </summary>
Task<bool> SetConfigByKeyAsync<T>(string key, T value);
}
public class ConfigService(IConfigRepository configRepository) : IConfigService
{
public async Task<T?> GetConfigByKeyAsync<T>(string key)
{
var config = await configRepository.GetByKeyAsync(key);
if (config == null || string.IsNullOrEmpty(config.Value))
{
return default;
}
return config.Type switch
{
ConfigType.Boolean => (T)(object)bool.Parse(config.Value),
ConfigType.String => (T)(object)config.Value,
ConfigType.Number => (T)Convert.ChangeType(config.Value, typeof(T)),
ConfigType.Json => JsonSerializer.Deserialize<T>(config.Value) ?? default,
_ => default
};
}
public async Task<bool> SetConfigByKeyAsync<T>(string key, T value)
{
if (value == null)
{
return false;
}
var config = await configRepository.GetByKeyAsync(key);
var type = typeof(T) switch
{
Type t when t == typeof(bool) => ConfigType.Boolean,
Type t when t == typeof(int)
|| t == typeof(double)
|| t == typeof(float)
|| t == typeof(decimal) => ConfigType.Number,
Type t when t == typeof(string) => ConfigType.String,
_ => ConfigType.Json
};
var valueStr = type switch
{
ConfigType.Boolean => value.ToString()!.ToLower(),
ConfigType.Number => value.ToString()!,
ConfigType.String => value as string ?? string.Empty,
ConfigType.Json => JsonSerializer.Serialize(value),
_ => throw new InvalidOperationException("Unsupported config type")
};
if (config == null)
{
config = new ConfigEntity
{
Key = key,
Type = type,
};
return await configRepository.AddAsync(config);
}
config.Value = valueStr;
config.Type = type;
return await configRepository.UpdateAsync(config);
}
}

View File

@@ -21,7 +21,7 @@ public class EmailHandleService(
IEmailMessageRepository emailRepo,
ITransactionRecordRepository trxRepo,
IEnumerable<IEmailParseServices> emailParsers,
IMessageRecordService messageRecordService,
IMessageService messageService,
ISmartHandleService smartHandleService
) : IEmailHandleService
{
@@ -62,23 +62,17 @@ public class EmailHandleService(
);
if (parsed == null || parsed.Length == 0)
{
await messageRecordService.AddAsync(
await messageService.AddAsync(
"邮件解析失败",
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。"
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。",
url: $"/balance?tab=email"
);
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
return true;
}
await messageRecordService.AddAsync(
"邮件解析成功",
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})已成功解析出 {parsed.Length} 条交易记录。"
);
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
// TODO 接入AI分类
// 目前已经
bool allSuccess = true;
var records = new List<TransactionRecord>();
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
@@ -92,7 +86,8 @@ public class EmailHandleService(
balance,
type,
occurredAt ?? date,
emailMessage.Id
emailMessage.Id,
$"邮件 By {GetEmailByName(to)}"
);
if (record == null)
@@ -104,7 +99,7 @@ public class EmailHandleService(
records.Add(record);
}
_ = await AnalyzeClassifyAsync(records.ToArray());
_ = AutoClassifyAsync(records.ToArray());
return allSuccess;
}
@@ -160,7 +155,8 @@ public class EmailHandleService(
balance,
type,
occurredAt ?? emailMessage.ReceivedDate,
emailMessage.Id
emailMessage.Id,
$"邮件 By {GetEmailByName(emailMessage.To)}"
);
if (record == null)
@@ -172,11 +168,53 @@ public class EmailHandleService(
records.Add(record);
}
_ = await AnalyzeClassifyAsync(records.ToArray());
_ = AutoClassifyAsync(records.ToArray());
return allSuccess;
}
private async Task AutoClassifyAsync(TransactionRecord[] records)
{
var clone = records.ToArray().DeepClone();
if(clone?.Any() != true)
{
return;
}
var analyzedList = await AnalyzeClassifyAsync(clone);
foreach (var analyzed in analyzedList)
{
var record = records.FirstOrDefault(r => r.Id == analyzed.Id);
if (record == null)
{
continue;
}
record.UnconfirmedClassify = analyzed.Classify;
record.UnconfirmedType = analyzed.Type;
record.Classify = string.Empty;
}
await trxRepo.UpdateRangeAsync(records);
// 消息
await messageService.AddAsync(
"交易记录待确认分类",
$"共有 {records.Length} 条交易记录待确认分类,请点击前往确认。",
MessageType.Url,
"/unconfirmed-classification"
);
}
private string GetEmailByName(string to)
{
return emailSettings.Value.SmtpList.FirstOrDefault(s => s.Email == to)?.Name ?? to;
}
private async Task<EmailMessage?> SaveEmailAsync(
string to,
string from,
@@ -241,7 +279,8 @@ public class EmailHandleService(
decimal balance,
TransactionType type,
DateTime occurredAt,
long emailMessageId
long emailMessageId,
string importFrom
)
{
// 根据 emailMessageId 检查是否已存在记录:存在则更新,否则新增
@@ -279,7 +318,7 @@ public class EmailHandleService(
Type = type,
OccurredAt = occurredAt,
EmailMessageId = emailMessageId,
ImportFrom = $"邮件"
ImportFrom = importFrom
};
var inserted = await trxRepo.AddAsync(trx);

View File

@@ -12,3 +12,6 @@ global using System.Linq;
global using Service.AppSettingModel;
global using System.Text.Json.Serialization;
global using System.Text.Json.Nodes;
global using Microsoft.Extensions.Configuration;
global using Common;
global using Service.AgentFramework;

View File

@@ -0,0 +1,42 @@
using Quartz;
namespace Service.Jobs;
/// <summary>
/// 预算归档定时任务
/// </summary>
[DisallowConcurrentExecution]
public class BudgetArchiveJob(
IServiceProvider serviceProvider,
ILogger<BudgetArchiveJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
try
{
logger.LogInformation("开始执行预算归档任务");
// 每个月1号执行归档上个月的数据
var targetDate = DateTime.Now.AddMonths(-1);
var year = targetDate.Year;
var month = targetDate.Month;
using var scope = serviceProvider.CreateScope();
var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>();
var result = await budgetService.ArchiveBudgetsAsync(year, month);
if (string.IsNullOrEmpty(result))
{
logger.LogInformation("归档 {Year}年{Month}月 预算任务执行成功", year, month);
}
else
{
logger.LogWarning("归档 {Year}年{Month}月 预算任务提示: {Result}", year, month, result);
}
}
catch (Exception ex)
{
logger.LogError(ex, "预算归档任务执行出错");
}
}
}

View File

@@ -17,11 +17,9 @@ public class PeriodicBillJob(
logger.LogInformation("开始执行周期性账单检查任务");
// 执行周期性账单检查
using (var scope = serviceProvider.CreateScope())
{
using var scope = serviceProvider.CreateScope();
var periodicService = scope.ServiceProvider.GetRequiredService<ITransactionPeriodicService>();
await periodicService.ExecutePeriodicBillsAsync();
}
logger.LogInformation("周期性账单检查任务执行完成");
}

View File

@@ -1,18 +1,18 @@
namespace Service;
public interface IMessageRecordService
public interface IMessageService
{
Task<(IEnumerable<MessageRecord> List, long Total)> GetPagedListAsync(int pageIndex, int pageSize);
Task<MessageRecord?> GetByIdAsync(long id);
Task<bool> AddAsync(MessageRecord message);
Task<bool> AddAsync(string title, string content);
Task<bool> AddAsync(string title, string content, MessageType type = MessageType.Text, string? url = null);
Task<bool> MarkAsReadAsync(long id);
Task<bool> MarkAllAsReadAsync();
Task<bool> DeleteAsync(long id);
Task<long> GetUnreadCountAsync();
}
public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessageRecordService
public class MessageService(IMessageRecordRepository messageRepo, INotificationService notificationService) : IMessageService
{
public async Task<(IEnumerable<MessageRecord> List, long Total)> GetPagedListAsync(int pageIndex, int pageSize)
{
@@ -29,15 +29,27 @@ public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessa
return await messageRepo.AddAsync(message);
}
public async Task<bool> AddAsync(string title, string content)
public async Task<bool> AddAsync(
string title,
string content,
MessageType type = MessageType.Text,
string? url = null
)
{
var message = new MessageRecord
{
Title = title,
Content = content,
MessageType = type,
Url = url,
IsRead = false
};
return await messageRepo.AddAsync(message);
var result = await messageRepo.AddAsync(message);
if (result)
{
await notificationService.SendNotificationAsync(title, url);
}
return result;
}
public async Task<bool> MarkAsReadAsync(long id)

View File

@@ -0,0 +1,93 @@
using WebPush;
namespace Service;
public interface INotificationService
{
Task<string> GetVapidPublicKeyAsync();
Task SubscribeAsync(Entity.PushSubscription subscription);
Task SendNotificationAsync(string message, string? url = null);
}
public class NotificationService(
IPushSubscriptionRepository subscriptionRepo,
IConfiguration configuration,
ILogger<NotificationService> logger) : INotificationService
{
private NotificationSettings GetSettings()
{
var settings = configuration.GetSection("NotificationSettings").Get<NotificationSettings>();
if (settings == null)
{
// Fallback or throw. For now, let's return empty to avoid crashing if not configured,
// but logging error is better.
logger.LogWarning("NotificationSettings not configured");
return new NotificationSettings();
}
return settings;
}
public Task<string> GetVapidPublicKeyAsync()
{
return Task.FromResult(GetSettings().PublicKey);
}
public async Task SubscribeAsync(Entity.PushSubscription subscription)
{
var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint);
if (existing != null)
{
existing.P256DH = subscription.P256DH;
existing.Auth = subscription.Auth;
existing.UpdateTime = DateTime.Now;
await subscriptionRepo.UpdateAsync(existing);
}
else
{
await subscriptionRepo.AddAsync(subscription);
}
}
public async Task SendNotificationAsync(string message, string? url = null)
{
var settings = GetSettings();
if (string.IsNullOrEmpty(settings.PublicKey) || string.IsNullOrEmpty(settings.PrivateKey))
{
logger.LogWarning("VAPID keys not configured, skipping notification");
return;
}
var vapidDetails = new VapidDetails(settings.Subject, settings.PublicKey, settings.PrivateKey);
var webPushClient = new WebPushClient();
var subscriptions = await subscriptionRepo.GetAllAsync();
var payload = System.Text.Json.JsonSerializer.Serialize(new
{
title = "System Notification",
body = message,
url = url ?? "/",
icon = "/pwa-192x192.png"
});
foreach (var sub in subscriptions)
{
try
{
var pushSubscription = new WebPush.PushSubscription(sub.Endpoint, sub.P256DH, sub.Auth);
await webPushClient.SendNotificationAsync(pushSubscription, payload, vapidDetails);
}
catch (WebPushException ex)
{
if (ex.StatusCode == System.Net.HttpStatusCode.Gone || ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await subscriptionRepo.DeleteAsync(sub.Id);
}
logger.LogError(ex, "Error sending push notification to {Endpoint}", sub.Endpoint);
}
catch (Exception ex)
{
logger.LogError(ex, "Error sending push notification to {Endpoint}", sub.Endpoint);
}
}
}
}

View File

@@ -84,7 +84,7 @@ public class OpenAiService(
}
using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(30);
http.Timeout = TimeSpan.FromSeconds(60 * 5);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new

View File

@@ -6,14 +6,15 @@
<ItemGroup>
<PackageReference Include="MailKit" />
<PackageReference Include="Microsoft.Agents.AI" />
<PackageReference Include="MimeKit" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="CsvHelper" />
<PackageReference Include="EPPlus" />
<PackageReference Include="HtmlAgilityPack" />
@@ -21,6 +22,8 @@
<PackageReference Include="Quartz.Extensions.Hosting" />
<PackageReference Include="JiebaNet.Analyser" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="WebPush" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
</Project>

View File

@@ -5,6 +5,8 @@ public interface ISmartHandleService
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction);
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
Task<TransactionParseResult?> ParseOneLineBillAsync(string text);
}
public class SmartHandleService(
@@ -12,7 +14,8 @@ public class SmartHandleService(
ITextSegmentService textSegmentService,
ILogger<SmartHandleService> logger,
ITransactionCategoryRepository categoryRepository,
IOpenAiService openAiService
IOpenAiService openAiService,
IConfigService configService
) : ISmartHandleService
{
public async Task SmartClassifyAsync(long[] transactionIds, Action<(string, string)> chunkAction)
@@ -22,6 +25,10 @@ public class SmartHandleService(
// 获取指定ID的账单作为样本
var sampleRecords = await transactionRepository.GetByIdsAsync(transactionIds);
sampleRecords = sampleRecords
.Where(x => string.IsNullOrEmpty(x.Classify))
.ToArray();
if (sampleRecords.Length == 0)
{
// await WriteEventAsync("error", "找不到指定的账单");
@@ -78,21 +85,8 @@ public class SmartHandleService(
}
}
// 获取所有分类
var categories = await categoryRepository.GetAllAsync();
// 构建分类信息
var categoryInfo = new StringBuilder();
foreach (var type in new[] { 0, 1, 2 })
{
var typeName = GetTypeName((TransactionType)type);
categoryInfo.AppendLine($"{typeName}: ");
var categoriesOfType = categories.Where(c => (int)c.Type == type).ToList();
foreach (var category in categoriesOfType)
{
categoryInfo.AppendLine($"- {category.Name}");
}
}
var categoryInfo = await GetCategoryInfoAsync();
// 构建账单分组信息
var billsInfo = new StringBuilder();
@@ -125,9 +119,14 @@ public class SmartHandleService(
- 使 NDJSON JSON
- JSON格式严格为{"reason": "交易摘要", "type": 0, "classify": "分类名称"}
- JSON格式严格为
{
"reason": "交易摘要",
"type": Number, // 交易类型0=支出1=收入2=不计入收支
"classify": "分类名称"
}
-
- "classify" "其他" JSON
- JSON对象
JSON对象NDJSON
""";
@@ -157,7 +156,12 @@ public class SmartHandleService(
{
if (sendedIds.Add(id))
{
var resultJson = JsonSerializer.Serialize(new { id, result.Classify, result.Type });
var resultJson = JsonSerializer.Serialize(new
{
id,
result.Classify,
result.Type
});
chunkAction(("data", resultJson));
}
}
@@ -250,11 +254,14 @@ public class SmartHandleService(
{
try
{
// 构建分类信息
var categoryInfo = await GetCategoryInfoAsync();
// 第一步使用AI生成聚合SQL查询
var now = DateTime.Now;
var sqlPrompt = $"""
当前日期:{now:yyyy年M月d日}{now:yyyy-MM-dd}
用户问题:{userInput}
var sqlPrompt = $$"""
当前日期:{{now:yyyy年M月d日}}{{now:yyyy-MM-dd}}
用户问题:{{userInput}}
数据库类型SQLite
数据库表名TransactionRecord
@@ -285,21 +292,29 @@ public class SmartHandleService(
- strftime('%Y-%m-%d', OccurredAt)
- 使 YEAR()MONTH()DAY() SQLite不支持
1
SELECT Classify, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-10-01' AND OccurredAt < '2026-01-01' AND (Classify LIKE '%%' OR Reason LIKE '%%' OR Reason LIKE '%%' OR Reason LIKE '%%') GROUP BY Classify ORDER BY TotalAmount DESC
SQL会被一下DOTNET代码执行,
```C#
public async Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql)
{
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
var result = new List<dynamic>();
2
SELECT strftime('%Y', OccurredAt) as Year, strftime('%m', OccurredAt) as Month, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-06-01' GROUP BY strftime('%Y', OccurredAt), strftime('%m', OccurredAt) ORDER BY Year, Month
foreach (System.Data.DataRow row in dt.Rows)
{
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
foreach (System.Data.DataColumn column in dt.Columns)
{
expando[column.ColumnName] = row[column];
}
result.Add(expando);
}
3
SELECT COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount, MAX(ABS(Amount)) as MaxAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-12-01' AND OccurredAt < '2026-01-01'
return result;
}
```
4 - 使
1000
SELECT OccurredAt, Classify, Reason, ABS(Amount) as Amount FROM TransactionRecord WHERE Type = 0 AND ABS(Amount) > 1000 ORDER BY Amount DESC LIMIT 50
{{categoryInfo}}
SQL语句
""";
@@ -316,6 +331,17 @@ public class SmartHandleService(
logger.LogInformation("AI生成的SQL: {Sql}", sqlText);
chunkAction(
JsonSerializer.Serialize(new
{
content = $"""
<pre style="max-height: 80px; font-size: 8px; overflow-y: auto; padding: 8px; border: 1px solid #3c3c3c">
{System.Net.WebUtility.HtmlEncode(sqlText)}
</pre>
"""
})
);
// 第二步执行动态SQL查询
List<dynamic> queryResults;
try
@@ -338,10 +364,15 @@ public class SmartHandleService(
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
var dataPrompt = $"""
当前日期:{DateTime.Now:yyyy年M月d日}
用户问题:{userInput}
【用户要求(重要)】
{userInput}
查询结果数据JSON格式
{dataJson}
@@ -370,6 +401,9 @@ public class SmartHandleService(
14. 给出实用建议:基于数据提供合理的财务建议
15. 语言专业、清晰、简洁
【用户补充(重要)】
{userPromptExtra}
直接输出纯净的HTML内容不要markdown代码块标记。
""";
@@ -391,6 +425,66 @@ public class SmartHandleService(
}
}
public async Task<TransactionParseResult?> ParseOneLineBillAsync(string text)
{
// 获取所有分类
var categories = await categoryRepository.GetAllAsync();
var categoryList = string.Join("、", categories.Select(c => $"{GetTypeName(c.Type)}-{c.Name}"));
// 构建分类信息
var categoryInfo = new StringBuilder();
foreach (var type in new[] { 0, 1, 2 })
{
var typeName = GetTypeName((TransactionType)type);
categoryInfo.AppendLine($"{typeName}: ");
var categoriesOfType = categories.Where(c => (int)c.Type == type).ToList();
foreach (var category in categoriesOfType)
{
categoryInfo.AppendLine($"- {category.Name}");
}
}
var sysPrompt = $$"""
你是一个智能账单解析助手。请从用户提供的文本中提取交易信息,包括日期、金额、摘要、类型和分类。
可用的分类列表:
{{categoryInfo}}
请返回 JSON 格式,包含以下字段:
- OccurredAt: 日期时间,格式 yyyy-MM-dd HH:mm:ss。当前系统时间为{{DateTime.Now:yyyy-MM-dd HH:mm:ss}}。
- Amount: 金额,数字。
- Reason: 备注/摘要,原文或其他补充信息。
- Type: 交易类型0=支出1=收入2=不计入收支。根据语义判断。
- Classify: 分类,请从以下现有分类中选择最匹配的一个:如果无法匹配,请留空。
返回示例
{
"OccurredAt": "2024-06-15 14:30:00",
"Amount": 150.75,
"Reason": "午餐消费",
"Type": 0,
"Classify": "餐饮"
}
JSON markdown
""";
var json = await openAiService.ChatAsync(sysPrompt, text);
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
// 清理可能的 markdown 标记
json = json.Replace("```json", "").Replace("```", "").Trim();
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
return JsonSerializer.Deserialize<TransactionParseResult>(json, options);
}
catch (Exception ex)
{
logger.LogError(ex, "解析账单失败");
return null;
}
}
/// <summary>
/// 查找匹配的右括号
/// </summary>
@@ -419,6 +513,27 @@ public class SmartHandleService(
_ => "未知"
};
}
private async Task<string> GetCategoryInfoAsync()
{
// 获取所有分类
var categories = await categoryRepository.GetAllAsync();
// 构建分类信息
var categoryInfo = new StringBuilder();
foreach (var type in new[] { 0, 1, 2 })
{
var typeName = GetTypeName((TransactionType)type);
categoryInfo.AppendLine($"{typeName}: ");
var categoriesOfType = categories.Where(c => (int)c.Type == type).ToList();
foreach (var category in categoriesOfType)
{
categoryInfo.AppendLine($"- {category.Name}");
}
}
return categoryInfo.ToString();
}
}
/// <summary>
@@ -435,3 +550,5 @@ public record GroupClassifyResult
[JsonPropertyName("type")]
public TransactionType Type { get; set; }
}
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);

View File

@@ -0,0 +1,82 @@
namespace Service;
/// <summary>
/// 智能处理服务 - 使用 Agent Framework 重构
/// </summary>
public interface ISmartHandleServiceV2
{
/// <summary>
/// 使用 Agent Framework 进行智能分类
/// </summary>
Task<AgentResult<ClassificationResult[]>> SmartClassifyAgentAsync(
long[] transactionIds,
Action<(string type, string data)> chunkAction);
/// <summary>
/// 使用 Agent Framework 解析单行账单
/// </summary>
Task<AgentResult<AgentFramework.TransactionParseResult?>> ParseOneLineBillAgentAsync(string text);
}
/// <summary>
/// 智能处理服务实现 - Agent Framework 版本
/// </summary>
public class SmartHandleServiceV2(
ClassificationAgent classificationAgent,
ParsingAgent parsingAgent,
ITransactionCategoryRepository categoryRepository,
ILogger<SmartHandleServiceV2> logger
) : ISmartHandleServiceV2
{
/// <summary>
/// 使用 Agent Framework 进行智能分类
/// </summary>
public async Task<AgentResult<ClassificationResult[]>> SmartClassifyAgentAsync(
long[] transactionIds,
Action<(string type, string data)> chunkAction)
{
try
{
logger.LogInformation("开始执行智能分类 AgentID 数量: {Count}", transactionIds.Length);
var result = await classificationAgent.ExecuteAsync(transactionIds, categoryRepository);
logger.LogInformation("分类完成:{Summary}", result.Summary);
return result;
}
catch (Exception ex)
{
logger.LogError(ex, "智能分类 Agent 执行失败");
throw;
}
}
/// <summary>
/// 使用 Agent Framework 解析单行账单
/// </summary>
public async Task<AgentResult<AgentFramework.TransactionParseResult?>> ParseOneLineBillAgentAsync(string text)
{
try
{
logger.LogInformation("开始解析账单: {Text}", text);
var result = await parsingAgent.ExecuteAsync(text);
if (result.Success)
{
logger.LogInformation("解析成功: {Summary}", result.Summary);
}
else
{
logger.LogWarning("解析失败: {Error}", result.Error);
}
return result;
}
catch (Exception ex)
{
logger.LogError(ex, "解析 Agent 执行失败");
throw;
}
}
}

View File

@@ -163,6 +163,13 @@ public class TransactionPeriodicService(
.Where(d => d >= 1 && d <= 31)
.ToList();
// 如果当前为月末,且配置中有大于当月天数的日期,则也执行
var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month);
if (today.Day == daysInMonth && executeDays.Any(d => d > daysInMonth))
{
return true;
}
return executeDays.Contains(today.Day);
}
@@ -175,7 +182,7 @@ public class TransactionPeriodicService(
return false;
// 计算当前是本季度的第几天
var quarterStartMonth = ((today.Month - 1) / 3) * 3 + 1;
var quarterStartMonth = (today.Month - 1) / 3 * 3 + 1;
var quarterStart = new DateTime(today.Year, quarterStartMonth, 1);
var daysSinceQuarterStart = (today - quarterStart).Days + 1;

File diff suppressed because one or more lines are too long

2
Web/.gitignore vendored
View File

@@ -400,3 +400,5 @@ FodyWeavers.xsd
.idea/
# ESLint
.eslintcache

View File

@@ -6,8 +6,31 @@
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
"source.fixAll": "explicit",
"source.fixAll.eslint": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"vue"
],
"eslint.format.enable": false,
"prettier.documentSelectors": [
"**/*.vue",
"**/*.js",
"**/*.jsx",
"**/*.css",
"**/*.html"
]
}

View File

@@ -1 +0,0 @@

View File

@@ -1,44 +0,0 @@
# email-bill
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Compile and Minify for Production
```sh
pnpm build
```
### Lint with [ESLint](https://eslint.org/)
```sh
pnpm lint
```

View File

@@ -1,26 +1,52 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import globals from 'globals'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default defineConfig([
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/node_modules/**', '.nuxt/**'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
files: ['**/*.{js,mjs,jsx}'],
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
rules: {
...js.configs.recommended.rules,
'indent': ['error', 2],
'quotes': ['error', 'single', { avoidEscape: true }],
'semi': ['error', 'never'],
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'comma-dangle': ['error', 'never'],
'no-trailing-spaces': 'error',
'no-multiple-empty-lines': ['error', { max: 1 }],
'space-before-function-paren': ['error', 'always'],
},
},
...pluginVue.configs['flat/recommended'],
{
files: ['**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'warn',
'indent': 'off',
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
skipFormatting,
])
{
files: ['**/service-worker.js', '**/src/registerServiceWorker.js'],
languageOptions: {
globals: {
...globals.serviceworker,
...globals.browser,
},
},
},
]

View File

@@ -1,6 +1,6 @@
{
"name": "email-bill",
"version": "0.0.0",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
@@ -15,6 +15,7 @@
},
"dependencies": {
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"pinia": "^3.0.4",
"vant": "^4.9.22",
"vue": "^3.5.25",

8
Web/pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
axios:
specifier: ^1.13.2
version: 1.13.2
dayjs:
specifier: ^1.11.19
version: 1.11.19
pinia:
specifier: ^3.0.4
version: 3.0.4(vue@3.5.26)
@@ -749,6 +752,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -2100,6 +2106,8 @@ snapshots:
csstype@3.2.3: {}
dayjs@1.11.19: {}
debug@4.4.3:
dependencies:
ms: 2.1.3

View File

@@ -3,7 +3,7 @@
"short_name": "账单",
"description": "个人账单管理与邮件解析",
"start_url": "/",
"display": "minimal-ui",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1989fa",
"orientation": "portrait-primary",

View File

@@ -1,4 +1,5 @@
const CACHE_NAME = 'emailbill-v1';
const VERSION = '1.0.0'; // Build Time: 2026-01-07 15:59:36
const CACHE_NAME = `emailbill-${VERSION}`;
const urlsToCache = [
'/',
'/index.html',
@@ -15,10 +16,16 @@ self.addEventListener('install', (event) => {
console.log('[Service Worker] 缓存文件');
return cache.addAll(urlsToCache);
})
.then(() => self.skipWaiting())
);
});
// 监听跳过等待消息
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// 激活 Service Worker
self.addEventListener('activate', (event) => {
console.log('[Service Worker] 激活中...');
@@ -51,11 +58,13 @@ self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(request)
.then((response) => {
// 克隆响应以便缓存
// 只针对成功的GET请求进行缓存
if (request.method === 'GET' && response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
}
return response;
})
.catch(() => {
@@ -66,7 +75,25 @@ self.addEventListener('fetch', (event) => {
return;
}
// 静态资源使用缓存优先策略
// 页面请求使用网络优先策略,确保能获取到最新的 index.html
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
return response;
})
.catch(() => {
return caches.match('/index.html') || caches.match(request);
})
);
return;
}
// 其他静态资源使用缓存优先策略
event.respondWith(
caches.match(request)
.then((response) => {
@@ -107,17 +134,29 @@ self.addEventListener('sync', (event) => {
// 推送通知
self.addEventListener('push', (event) => {
console.log('[Service Worker] 收到推送消息');
let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' };
if (event.data) {
try {
const json = event.data.json();
data = { ...data, ...json };
} catch {
data.body = event.data.text();
}
}
const options = {
body: event.data ? event.data.text() : '您有新的账单消息',
icon: '/icons/icon-192x192.png',
body: data.body,
icon: data.icon,
badge: '/icons/icon-72x72.png',
vibrate: [200, 100, 200],
tag: 'emailbill-notification',
requireInteraction: false
requireInteraction: false,
data: { url: data.url }
};
event.waitUntil(
self.registration.showNotification('账单管理', options)
self.registration.showNotification(data.title, options)
);
});
@@ -125,8 +164,21 @@ self.addEventListener('push', (event) => {
self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] 通知被点击');
event.notification.close();
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.openWindow('/')
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
// 如果已经打开了该 URL则聚焦
for (let i = 0; i < windowClients.length; i++) {
const client = windowClients[i];
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// 否则打开新窗口
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});

View File

@@ -2,23 +2,35 @@
<van-config-provider :theme="theme" class="app-provider">
<div class="app-root">
<RouterView />
<van-tabbar v-model="active" v-show="showTabbar">
<van-tabbar v-show="showTabbar" v-model="active">
<van-tabbar-item name="ccalendar" icon="notes" to="/calendar">
日历
</van-tabbar-item>
<van-tabbar-item name="statistics" icon="chart-trending-o" to="/" @click="handleTabClick('/statistics')">
统计
</van-tabbar-item>
<van-tabbar-item name="balance" icon="balance-list" to="/balance" @click="handleTabClick('/balance')">
<van-tabbar-item
name="balance"
icon="balance-list"
:to="messageStore.unreadCount > 0 ? '/balance?tab=message' : '/balance'"
:badge="messageStore.unreadCount || null"
@click="handleTabClick('/balance')"
>
账单
</van-tabbar-item>
<van-tabbar-item name="message" icon="comment" to="/message" @click="handleTabClick('/message')" :badge="messageStore.unreadCount || null">
消息
<van-tabbar-item name="budget" icon="bill-o" to="/budget" @click="handleTabClick('/budget')">
预算
</van-tabbar-item>
<van-tabbar-item name="setting" icon="setting" to="/setting">
设置
</van-tabbar-item>
</van-tabbar>
<GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess"/>
<div v-if="needRefresh" class="update-toast" @click="updateServiceWorker">
<van-icon name="upgrade" class="update-icon" />
<span>新版本可用点击刷新</span>
</div>
</div>
</van-config-provider>
</template>
@@ -27,24 +39,38 @@
import { RouterView, useRoute } from 'vue-router'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useMessageStore } from '@/stores/message'
import GlobalAddBill from '@/components/Global/GlobalAddBill.vue'
import { needRefresh, updateServiceWorker } from './registerServiceWorker'
import '@/styles/common.css'
const messageStore = useMessageStore()
const updateVh = () => {
// 获取真实的视口高度PWA 模式下准确)
const vh = window.innerHeight
// 设置 CSS 变量,让所有组件使用准确的视口高度
document.documentElement.style.setProperty('--vh', `${vh}px`)
}
// 修复 PWA 模式下键盘收起页面不回弹的问题
const handleFocusOut = () => {
if (/(iPhone|iPad|iPod|iOS|Android)/i.test(navigator.userAgent)) {
// 延迟一小段时间执行,确保键盘收起动作已开始
setTimeout(() => {
// 强制回到顶部
window.scrollTo(0, 0)
// 同时也触发一次高度更新
updateVh()
}, 100)
}
}
onMounted(() => {
updateVh()
window.addEventListener('resize', updateVh)
// 监听 iOS Safari 视口变化
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateVh)
}
// 注册全局失去焦点监听
document.addEventListener('focusout', handleFocusOut)
})
onUnmounted(() => {
@@ -52,6 +78,8 @@ onUnmounted(() => {
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateVh)
}
// 销毁监听
document.removeEventListener('focusout', handleFocusOut)
})
const route = useRoute()
@@ -61,7 +89,8 @@ const showTabbar = computed(() => {
route.path === '/calendar' ||
route.path === '/message' ||
route.path === '/setting' ||
route.path === '/balance'
route.path === '/balance' ||
route.path === '/budget'
})
const active = ref('')
@@ -77,15 +106,20 @@ const updateTheme = () => {
let mediaQuery
onMounted(() => {
updateTheme()
messageStore.updateUnreadCount()
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateTheme)
setActive(route.path)
})
setInterval(() => {
messageStore.updateUnreadCount()
}, 60 * 1000) // 每60秒更新一次未读消息数
// 监听路由变化调整
watch(() => route.path, (newPath) => {
setActive(newPath)
messageStore.updateUnreadCount()
})
const setActive = (path) => {
@@ -94,11 +128,12 @@ const setActive = (path) => {
case '/calendar':
return 'ccalendar'
case '/balance':
return 'balance'
case '/message':
return 'message'
return 'balance'
case '/setting':
return 'setting'
case '/budget':
return 'budget'
default:
return 'statistics'
}
@@ -106,6 +141,13 @@ const setActive = (path) => {
console.log(active.value, path)
}
const isShowAddBill = computed(() => {
return route.path === '/'
|| route.path === '/calendar'
|| route.path === '/balance'
|| route.path === '/message'
})
onUnmounted(() => {
if (mediaQuery) {
mediaQuery.removeEventListener('change', updateTheme)
@@ -119,6 +161,12 @@ const handleTabClick = (path) => {
}
}
const handleAddTransactionSuccess = () => {
// 当添加交易成功时,通知当前页面刷新数据
const event = new Event('transactions-changed')
window.dispatchEvent(event)
}
</script>
<style scoped>
@@ -168,4 +216,31 @@ const handleTabClick = (path) => {
font-size: 12px;
pointer-events: auto;
}
.update-toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background-color: var(--van-primary-color);
color: white;
padding: 10px 20px;
border-radius: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 2000;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.update-toast:active {
transform: translateX(-50%) scale(0.95);
}
.update-icon {
font-size: 18px;
}
</style>

View File

@@ -1,5 +1,6 @@
import axios from 'axios'
import { showToast } from 'vant'
import { useAuthStore } from '@/stores/auth'
/**
* 账单导入相关 API
@@ -21,7 +22,8 @@ export const uploadBillFile = (file, type) => {
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${useAuthStore().token || ''}`
},
timeout: 60000 // 文件上传增加超时时间
}).then(response => {

98
Web/src/api/budget.js Normal file
View File

@@ -0,0 +1,98 @@
import request from './request'
/**
* 获取预算列表
* @param {string} referenceDate 参考日期 (可选)
*/
export function getBudgetList(referenceDate) {
return request({
url: '/Budget/GetList',
method: 'get',
params: { referenceDate }
})
}
/**
* 获取单个预算统计
* @param {number} id 预算ID
* @param {string} referenceDate 参考日期
*/
export function getBudgetStatistics(id, referenceDate) {
return request({
url: '/Budget/GetStatistics',
method: 'get',
params: { id, referenceDate }
})
}
/**
* 创建预算
* @param {object} data 预算数据
*/
export function createBudget(data) {
return request({
url: '/Budget/Create',
method: 'post',
data
})
}
/**
* 更新预算
* @param {object} data 预算数据
*/
export function updateBudget(data) {
return request({
url: '/Budget/Update',
method: 'post',
data
})
}
/**
* 删除预算
* @param {number} id 预算ID
*/
export function deleteBudget(id) {
return request({
url: `/Budget/DeleteById/${id}`,
method: 'delete'
})
}
/**
* 获取分类统计信息(月度和年度)
* @param {string} category 分类 (Expense/Income/Savings)
* @param {string} referenceDate 参考日期 (可选)
*/
export function getCategoryStats(category, referenceDate) {
return request({
url: '/Budget/GetCategoryStats',
method: 'get',
params: { category, referenceDate }
})
}
/**
* 获取未被预算覆盖的分类统计信息
* @param {number} category 预算分类
* @param {string} referenceDate 参考日期
*/
export function getUncoveredCategories(category, referenceDate) {
return request({
url: '/Budget/GetUncoveredCategories',
method: 'get',
params: { category, referenceDate }
})
}
/**
* 归档预算
* @param {number} year 年份
* @param {number} month 月份
*/
export function archiveBudgets(year, month) {
return request({
url: `/Budget/ArchiveBudgetsAsync/${year}/${month}`,
method: 'post'
})
}

28
Web/src/api/config.js Normal file
View File

@@ -0,0 +1,28 @@
import request from './request'
/**
* 获取配置值
* @param {string} key - 配置的key
* @returns {Promise<{success: boolean, data: string}>}
*/
export const getConfig = (key) => {
return request({
url: '/Config/GetConfig',
method: 'get',
params: { key }
})
}
/**
* 设置配置值
* @param {string} key - 配置的key
* @param {string} value - 配置的值
* @returns {Promise<{success: boolean}>}
*/
export const setConfig = (key, value) => {
return request({
url: '/Config/SetConfig',
method: 'post',
params: { key, value }
})
}

32
Web/src/api/job.js Normal file
View File

@@ -0,0 +1,32 @@
import request from '@/api/request'
export function getJobs() {
return request({
url: '/Job/GetJobs',
method: 'get'
})
}
export function executeJob(jobName) {
return request({
url: '/Job/Execute',
method: 'post',
data: { jobName }
})
}
export function pauseJob(jobName) {
return request({
url: '/Job/Pause',
method: 'post',
data: { jobName }
})
}
export function resumeJob(jobName) {
return request({
url: '/Job/Resume',
method: 'post',
data: { jobName }
})
}

View File

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

View File

@@ -53,13 +53,14 @@ request.interceptors.response.use(
case 400:
message = data?.message || '请求参数错误'
break
case 401:
case 401: {
message = '未授权,请重新登录'
// 清除登录状态并跳转到登录页
const authStore = useAuthStore()
authStore.logout()
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
break
}
case 403:
message = '拒绝访问'
break

View File

@@ -19,6 +19,29 @@ export const getTransactionList = (params = {}) => {
})
}
/**
* 获取待确认分类的交易记录列表
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getUnconfirmedTransactionList = () => {
return request({
url: '/TransactionRecord/GetUnconfirmedList',
method: 'get'
})
}
/**
* 全部确认待确认的交易分类
* @returns {Promise<{success: boolean, data: number}>}
*/
export const confirmAllUnconfirmed = (ids) => {
return request({
url: '/TransactionRecord/ConfirmAllUnconfirmed',
method: 'post',
data: { ids }
})
}
/**
* 根据ID获取交易记录详情
* @param {number} id - 交易记录ID
@@ -200,3 +223,42 @@ export const nlpAnalysis = (userInput) => {
data: { 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 - 用户输入的自然语言文本
* @returns {Promise<{success: boolean, data: Object}>}
*/
export const parseOneLine = (text) => {
return request({
url: '/TransactionRecord/ParseOneLine',
method: 'post',
data: { text }
})
}

View File

@@ -0,0 +1,43 @@
<template>
<van-dialog
v-model:show="show"
title="新增交易分类"
show-cancel-button
@confirm="handleConfirm"
>
<van-field v-model="classifyName" placeholder="请输入新的交易分类" />
</van-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { showToast } from 'vant'
const emit = defineEmits(['confirm'])
const show = ref(false)
const classifyName = ref('')
// 打开弹窗
const open = () => {
classifyName.value = ''
show.value = true
}
// 确认
const handleConfirm = () => {
if (!classifyName.value.trim()) {
showToast('请输入分类名称')
return
}
emit('confirm', classifyName.value.trim())
show.value = false
classifyName.value = ''
}
// 暴露方法给父组件
defineExpose({
open
})
</script>

View File

@@ -0,0 +1,240 @@
<template>
<div class="bill-form">
<van-form @submit="handleSubmit">
<van-cell-group inset>
<!-- 日期时间 -->
<van-field label="时间">
<template #input>
<div style="display: flex; gap: 16px">
<div @click="showDatePicker = true">{{ form.date }}</div>
<div @click="showTimePicker = true">{{ form.time }}</div>
</div>
</template>
</van-field>
<!-- 金额 -->
<van-field
v-model="form.amount"
name="amount"
label="金额"
type="number"
placeholder="0.00"
:rules="[{ required: true, message: '请输入金额' }]"
/>
<!-- 备注 -->
<van-field
v-model="form.note"
name="note"
label="摘要"
placeholder="摘要信息"
rows="2"
autosize
type="textarea"
/>
<!-- 交易类型 -->
<van-field name="type" label="类型">
<template #input>
<van-radio-group v-model="form.type" direction="horizontal" @change="handleTypeChange">
<van-radio :name="0">支出</van-radio>
<van-radio :name="1">收入</van-radio>
<van-radio :name="2">不计</van-radio>
</van-radio-group>
</template>
</van-field>
<!-- 分类 -->
<van-field name="category" label="分类">
<template #input>
<span v-if="!categoryName" style="color: #c8c9cc;">请选择分类</span>
<span v-else>{{ categoryName }}</span>
</template>
</van-field>
<!-- 分类选择组件 -->
<ClassifySelector
v-model="categoryName"
:type="form.type"
/>
</van-cell-group>
<div class="actions">
<van-button round block type="primary" native-type="submit" :loading="loading">
{{ submitText }}
</van-button>
<slot name="actions"></slot>
</div>
</van-form>
<!-- 日期选择弹窗 -->
<van-popup v-model:show="showDatePicker" position="bottom" round teleport="body">
<van-date-picker
v-model="currentDate"
title="选择日期"
@confirm="onConfirmDate"
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 时间选择弹窗 -->
<van-popup v-model:show="showTimePicker" position="bottom" round teleport="body">
<van-time-picker
v-model="currentTime"
title="选择时间"
@confirm="onConfirmTime"
@cancel="showTimePicker = false"
/>
</van-popup>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import { showToast } from 'vant'
import dayjs from 'dayjs'
import ClassifySelector from '@/components/ClassifySelector.vue'
const props = defineProps({
initialData: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
},
submitText: {
type: String,
default: '保存'
}
})
const emit = defineEmits(['submit'])
// 表单数据
const form = ref({
type: 0, // 0: 支出, 1: 收入, 2: 不计
amount: '',
date: dayjs().format('YYYY-MM-DD'),
time: dayjs().format('HH:mm'),
note: ''
})
const categoryName = ref('')
const isSyncing = ref(false)
// 弹窗控制
const showDatePicker = ref(false)
const showTimePicker = ref(false)
// 日期时间临时变量 (Vant DatePicker 需要数组 or 特定格式)
const currentDate = ref(dayjs().format('YYYY-MM-DD').split('-'))
const currentTime = ref(dayjs().format('HH:mm').split(':'))
// 初始化数据
const initForm = async () => {
if (props.initialData) {
isSyncing.value = true
const { occurredAt, amount, reason, type, classify } = props.initialData
if (occurredAt) {
const dt = dayjs(occurredAt)
form.value.date = dt.format('YYYY-MM-DD')
form.value.time = dt.format('HH:mm')
currentDate.value = form.value.date.split('-')
currentTime.value = form.value.time.split(':')
}
if (amount !== undefined) form.value.amount = amount
if (reason !== undefined) form.value.note = reason
if (type !== undefined) form.value.type = type
// 如果有传入分类名称,尝试设置
if (classify) {
categoryName.value = classify
}
nextTick(() => {
isSyncing.value = false
})
}
}
onMounted(() => {
initForm()
})
// 监听 initialData 变化 (例如重新解析后)
watch(() => props.initialData, () => {
initForm()
}, { deep: true })
const handleTypeChange = (newType) => {
if (!isSyncing.value) {
categoryName.value = ''
}
}
const onConfirmDate = ({ selectedValues }) => {
form.value.date = selectedValues.join('-')
showDatePicker.value = false
}
const onConfirmTime = ({ selectedValues }) => {
form.value.time = selectedValues.join(':')
showTimePicker.value = false
}
const handleSubmit = () => {
if (!form.value.amount) {
showToast('请输入金额')
return
}
if (!categoryName.value) {
showToast('请选择分类')
return
}
const fullDateTime = `${form.value.date}T${form.value.time}:00`
const payload = {
occurredAt: fullDateTime,
classify: categoryName.value,
amount: parseFloat(form.value.amount),
reason: form.value.note || '',
type: form.value.type
}
emit('submit', payload)
}
// 暴露重置方法给父组件
const reset = () => {
form.value.amount = ''
form.value.note = ''
// 保留日期和类型
}
defineExpose({ reset })
</script>
<style scoped>
.bill-form {
padding-top: 10px;
}
.actions {
margin: 20px 16px;
}
.classify-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
}
.classify-btn {
flex: 0 0 auto;
min-width: 70px;
border-radius: 16px;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="manual-bill-add">
<BillForm
ref="billFormRef"
:loading="saving"
@submit="handleSave"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { showToast } from 'vant'
import { createTransaction } from '@/api/transactionRecord'
import BillForm from './BillForm.vue'
const emit = defineEmits(['success'])
const saving = ref(false)
const billFormRef = ref(null)
const handleSave = async (payload) => {
saving.value = true
try {
const res = await createTransaction(payload)
if (!res.success) {
throw new Error(res.message || '保存失败')
}
showToast('保存成功')
// 重置表单
if (billFormRef.value) {
billFormRef.value.reset()
}
emit('success')
} catch (err) {
console.error(err)
showToast('保存失败: ' + err.message)
} finally {
saving.value = false
}
}
</script>
<style scoped>
.manual-bill-add {
/* padding-top: 10px; */
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div>
<div v-if="!parseResult" class="input-section" style="margin: 12px 12px 0 16px;">
<van-field
v-model="text"
type="textarea"
rows="4"
placeholder="例如1月3日 晚餐 45.5 美团"
class="bill-input"
:disabled="parsing || saving"
/>
<div class="actions">
<van-button
type="primary"
round
block
:loading="parsing"
:disabled="!text.trim()"
@click="handleParse"
>
智能解析
</van-button>
</div>
</div>
<div v-if="parseResult" class="result-section">
<BillForm
:initial-data="parseResult"
:loading="saving"
submit-text="确认保存"
@submit="handleSave"
>
<template #actions>
<van-button
plain
round
block
class="mt-2"
@click="parseResult = null"
>
重新输入
</van-button>
</template>
</BillForm>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { showToast } from 'vant'
import BillForm from './BillForm.vue'
import { createTransaction, parseOneLine } from '@/api/transactionRecord'
const emit = defineEmits(['success'])
const text = ref('')
const parsing = ref(false)
const saving = ref(false)
const parseResult = ref(null)
const handleParse = async () => {
if (!text.value.trim()) return
parsing.value = true
parseResult.value = null
try {
const res = await parseOneLine(text.value)
if(!res.success){
throw new Error(res.message || '解析失败')
}
parseResult.value = res.data
} catch (err) {
console.error(err)
showToast('解析失败:' + err.message)
} finally {
parsing.value = false
}
}
const handleSave = async (payload) => {
saving.value = true
try {
const res = await createTransaction(payload)
if (!res.success) {
throw new Error(res.message || '保存失败')
}
showToast('保存成功')
text.value = ''
parseResult.value = null
emit('success')
} catch (err) {
console.error(err)
showToast('保存失败:' + err.message)
} finally {
saving.value = false
}
}
</script>
<style scoped>
.bill-input {
background-color: var(--van-background-2);
border-radius: 8px;
margin-bottom: 16px;
}
.actions {
margin-top: 16px;
}
.mt-2 {
margin-top: 8px;
}
.preview-card {
margin-bottom: 16px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #ebedf0;
}
</style>

View File

@@ -0,0 +1,613 @@
<template>
<div class="common-card budget-card" @click="toggleExpand">
<div class="budget-content-wrapper">
<!-- 折叠状态 -->
<div v-if="!isExpanded" class="budget-collapsed">
<div class="collapsed-header">
<div class="budget-info">
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3 class="card-title">{{ budget.name }}</h3>
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
({{ budget.selectedCategories.join('') }})
</span>
</div>
<van-icon name="arrow-down" class="expand-icon" />
</div>
<div class="collapsed-footer">
<div class="collapsed-item">
<span class="compact-label">实际/目标</span>
<span class="compact-value">
<slot name="collapsed-amount">
{{ budget.current !== undefined && budget.limit !== undefined
? `¥${budget.current?.toFixed(0) || 0} / ¥${budget.limit?.toFixed(0) || 0}`
: '--' }}
</slot>
</span>
</div>
<div class="collapsed-item">
<span class="compact-label">达成率</span>
<span class="compact-value" :class="percentClass">{{ percentage }}%</span>
</div>
</div>
</div>
<!-- 展开状态 -->
<Transition v-else :name="transitionName">
<div :key="budget.period" class="budget-inner-card">
<div class="card-header" style="margin-bottom: 0;">
<div class="budget-info">
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3 class="card-title" style="max-width: 120px;">{{ budget.name }}</h3>
</div>
<div class="header-actions">
<slot name="actions">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="small"
:type="showDescription ? 'primary' : 'default'"
plain
@click.stop="showDescription = !showDescription"
/>
<van-button
icon="orders-o"
size="small"
plain
title="查询关联账单"
@click.stop="handleQueryBills"
/>
<template v-if="budget.category !== 2">
<van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template>
</slot>
</div>
</div>
<div class="budget-body">
<div v-if="budget.selectedCategories?.length" class="category-tags">
<van-tag
v-for="cat in budget.selectedCategories"
:key="cat"
size="mini"
class="category-tag"
plain
round
>
{{ cat }}
</van-tag>
</div>
<div class="amount-info">
<slot name="amount-info"></slot>
</div>
<div class="progress-section">
<slot name="progress-info">
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
<van-progress
:percentage="Math.min(percentage, 100)"
stroke-width="8"
:color="progressColor"
:show-pivot="false"
/>
<span class="percent" :class="percentClass">{{ percentage }}%</span>
</slot>
</div>
<div class="progress-section time-progress">
<span class="period-type">时间进度</span>
<van-progress
:percentage="timePercentage"
stroke-width="4"
color="#969799"
:show-pivot="false"
/>
<span class="percent">{{ timePercentage }}%</span>
</div>
<van-collapse-transition>
<div v-if="budget.description && showDescription" class="budget-description">
<div class="description-content rich-html-content" v-html="budget.description"></div>
</div>
</van-collapse-transition>
</div>
<div class="card-footer">
<div class="period-navigation" @click.stop>
<van-button
icon="arrow-left"
class="nav-icon"
plain
size="small"
style="width: 50px;"
@click="handleSwitch(-1)"
/>
<span class="period-text">{{ budget.period }}</span>
<van-button
icon="arrow"
class="nav-icon"
plain
size="small"
style="width: 50px;"
:disabled="isNextDisabled"
@click="handleSwitch(1)"
/>
</div>
</div>
</div>
</Transition>
</div>
<!-- 关联账单列表弹窗 -->
<PopupContainer
v-model="showBillListModal"
title="关联账单列表"
height="75%"
>
<TransactionList
:transactions="billList"
:loading="billLoading"
:finished="true"
:show-delete="false"
:show-checkbox="false"
@click="handleBillClick"
@delete="handleBillDelete"
/>
</PopupContainer>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { BudgetPeriodType } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
import TransactionList from '@/components/TransactionList.vue'
import { getTransactionList } from '@/api/transactionRecord'
const props = defineProps({
budget: {
type: Object,
required: true
},
progressColor: {
type: String,
default: '#1989fa'
},
percentClass: {
type: [String, Object],
default: ''
},
periodLabel: {
type: String,
default: ''
}
})
const emit = defineEmits(['switch-period', 'click'])
const isExpanded = ref(props.budget.category === 2)
const transitionName = ref('slide-left')
const showDescription = ref(false)
const showBillListModal = ref(false)
const billList = ref([])
const billLoading = ref(false)
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
}
const isNextDisabled = computed(() => {
if (!props.budget.periodEnd) return false
return new Date(props.budget.periodEnd) > new Date()
})
const handleSwitch = (direction) => {
transitionName.value = direction > 0 ? 'slide-left' : 'slide-right'
emit('switch-period', direction)
}
const handleQueryBills = async () => {
showBillListModal.value = true
billLoading.value = true
try {
const classify = props.budget.selectedCategories
? props.budget.selectedCategories.join(',')
: ''
if (classify === '') {
// 如果没有选中任何分类,则不查询
billList.value = []
billLoading.value = false
return
}
const response = await getTransactionList({
page: 1,
pageSize: 100,
startDate: props.budget.periodStart,
endDate: props.budget.periodEnd,
classify: classify,
type: props.budget.category,
sortByAmount: true
})
if(response.success) {
billList.value = response.data || []
} else {
billList.value = []
}
} catch (error) {
console.error('查询账单列表失败:', error)
billList.value = []
} finally {
billLoading.value = false
}
}
const percentage = computed(() => {
if (!props.budget.limit) return 0
return Math.round((props.budget.current / props.budget.limit) * 100)
})
const timePercentage = computed(() => {
if (!props.budget.periodStart || !props.budget.periodEnd) return 0
const start = new Date(props.budget.periodStart).getTime()
const end = new Date(props.budget.periodEnd).getTime()
const now = new Date().getTime()
if (now <= start) return 0
if (now >= end) return 100
return Math.round(((now - start) / (end - start)) * 100)
})
</script>
<style scoped>
.budget-card {
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
padding: 8px 12px;
overflow: hidden;
position: relative;
cursor: pointer;
}
.budget-content-wrapper {
position: relative;
width: 100%;
}
.budget-inner-card {
width: 100%;
}
/* 折叠状态样式 */
.budget-collapsed {
display: flex;
flex-direction: column;
gap: 6px;
}
.collapsed-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.collapsed-left {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
.card-title-compact {
margin: 0;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.status-tag-compact) {
padding: 2px 6px !important;
font-size: 11px !important;
height: auto !important;
flex-shrink: 0;
}
.collapsed-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.collapsed-item {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.collapsed-item:first-child {
flex: 2;
}
.collapsed-item:last-child {
flex: 1;
}
.compact-label {
font-size: 12px;
color: #969799;
line-height: 1.2;
}
.compact-value {
font-size: 13px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-value.warning {
color: #ff976a;
}
.compact-value.income {
color: #07c160;
}
.expand-icon {
color: #1989fa;
font-size: 14px;
transition: transform 0.3s ease;
flex-shrink: 0;
}
.collapse-icon {
color: #1989fa;
font-size: 16px;
cursor: pointer;
}
/* 切换动画 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-to {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-leave-to {
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-active,
.slide-right-leave-active {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.budget-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.card-title {
margin: 0;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.card-subtitle {
font-size: 12px;
color: #969799;
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.header-actions {
display: flex;
gap: 8px;
}
.category-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 8px 0 4px;
}
.category-tag {
opacity: 0.7;
font-size: 10px;
}
.amount-info {
display: flex;
justify-content: space-between;
margin: 12px 0;
text-align: center;
}
:deep(.info-item) .label {
font-size: 12px;
color: #969799;
margin-bottom: 2px;
}
:deep(.info-item) .value {
font-size: 15px;
font-weight: 600;
}
:deep(.value.expense) {
color: #ee0a24;
}
:deep(.value.income) {
color: #07c160;
}
.progress-section {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
color: #646566;
}
.progress-section :deep(.van-progress) {
flex: 1;
}
.period-type {
white-space: nowrap;
width: 65px;
}
.percent {
white-space: nowrap;
width: 35px;
text-align: right;
}
.percent.warning {
color: #ff976a;
font-weight: bold;
}
.percent.income {
color: #07c160;
font-weight: bold;
}
.time-progress {
margin-top: -8px;
opacity: 0.8;
}
.time-progress .period-type,
.time-progress .percent {
font-size: 11px;
}
.budget-description {
margin-top: 8px;
background-color: #f7f8fa;
border-radius: 4px;
padding: 8px;
}
.description-content {
font-size: 11px;
color: #646566;
line-height: 1.4;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
color: #969799;
padding: 12px 12px 0;
padding-top: 8px;
border-top: 1px solid #ebedf0;
}
.period-navigation {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.period-text {
font-size: 14px;
font-weight: 500;
color: #323233;
}
.nav-icon {
padding: 4px;
font-size: 12px;
color: #1989fa;
}
@media (prefers-color-scheme: dark) {
.card-footer {
border-top-color: #2c2c2c;
}
.period-text {
color: #f5f5f5;
}
.budget-description {
background-color: #2c2c2c;
}
.description-content {
color: #969799;
}
.collapsed-row .value {
color: #f5f5f5;
}
}
</style>

View File

@@ -0,0 +1,191 @@
<template>
<PopupContainer
v-model="visible"
:title="isEdit ? `编辑${getCategoryName(form.category)}预算` : `新增${getCategoryName(form.category)}预算`"
height="75%"
>
<div class="add-budget-form">
<van-form>
<van-cell-group inset>
<van-field
v-model="form.name"
name="name"
label="预算名称"
placeholder="例如:每月餐饮、年度奖金"
:rules="[{ required: true, message: '请填写预算名称' }]"
/>
<van-field name="type" label="统计周期">
<template #input>
<van-radio-group
v-model="form.type"
direction="horizontal"
:disabled="isEdit"
>
<van-radio :name="BudgetPeriodType.Month"></van-radio>
<van-radio :name="BudgetPeriodType.Year"></van-radio>
</van-radio-group>
</template>
</van-field>
<van-field
v-model="form.limit"
type="number"
name="limit"
label="预算金额"
placeholder="0.00"
:rules="[{ required: true, message: '请填写预算金额' }]"
>
<template #extra>
<span></span>
</template>
</van-field>
<van-field label="相关分类">
<template #input>
<div v-if="form.selectedCategories.length === 0" style="color: #c8c9cc;">可多选分类</div>
<div v-else class="selected-categories">
<span class="ellipsis-text">
{{ form.selectedCategories.join('、') }}
</span>
</div>
</template>
</van-field>
<ClassifySelector
v-model="form.selectedCategories"
:type="budgetType"
multiple
:show-add="false"
:show-clear="false"
/>
</van-cell-group>
</van-form>
</div>
<template #footer>
<van-button block round type="primary" @click="onSubmit">保存预算</van-button>
</template>
</PopupContainer>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { showToast } from 'vant'
import { createBudget, updateBudget } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
const emit = defineEmits(['success'])
const visible = ref(false)
const isEdit = ref(false)
const form = reactive({
id: undefined,
name: '',
type: BudgetPeriodType.Month,
category: BudgetCategory.Expense,
limit: '',
selectedCategories: []
})
const open = ({
data,
isEditFlag,
category
}) => {
if(category === undefined) {
showToast('缺少必要参数category')
return
}
isEdit.value = isEditFlag
if (data) {
Object.assign(form, {
id: data.id,
name: data.name,
type: data.type,
category: category,
limit: data.limit,
selectedCategories: data.selectedCategories ? [...data.selectedCategories] : []
})
} else {
Object.assign(form, {
id: undefined,
name: '',
type: BudgetPeriodType.Month,
category: category,
limit: '',
selectedCategories: []
})
}
visible.value = true
}
defineExpose({
open
})
const budgetType = computed(() => {
return form.category === BudgetCategory.Expense ? 0 : (form.category === BudgetCategory.Income ? 1 : 2)
})
const onSubmit = async () => {
try {
const data = {
...form,
limit: parseFloat(form.limit),
selectedCategories: form.selectedCategories
}
const res = form.id ? await updateBudget(data) : await createBudget(data)
if (res.success) {
showToast('保存成功')
visible.value = false
emit('success')
}
} catch (err) {
showToast(err.message || '保存失败')
console.error('保存预算失败', err)
}
}
const getCategoryName = (category) => {
switch(category) {
case BudgetCategory.Expense:
return '支出'
case BudgetCategory.Income:
return '收入'
case BudgetCategory.Savings:
return '存款'
default:
return ''
}
}
</script>
<style scoped>
.add-budget-form {
padding: 20px 0;
}
.selected-categories {
display: flex;
align-items: center;
padding: 4px 0;
width: 100%;
overflow: hidden;
}
.ellipsis-text {
font-size: 14px;
color: #323233;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.no-data {
font-size: 13px;
color: #969799;
padding: 8px 16px;
}
</style>

View File

@@ -0,0 +1,272 @@
<template>
<div class="summary-container">
<transition :name="transitionName" mode="out-in">
<div v-if="stats && (stats.month || stats.year)" :key="dateKey" class="summary-card common-card">
<!-- 左切换按钮 -->
<div class="nav-arrow left" @click.stop="changeMonth(-1)">
<van-icon name="arrow-left" />
</div>
<div class="summary-content">
<template v-for="(config, key) in periodConfigs" :key="key">
<div class="summary-item">
<div class="label">{{ config.label }}{{ title }}</div>
<div class="value" :class="getValueClass(stats[key]?.rate || '0.0')">
{{ stats[key]?.rate || '0.0' }}<span class="unit">%</span>
</div>
<div class="sub-info">
<span class="amount">¥{{ formatMoney(stats[key]?.current || 0) }}</span>
<span class="separator">/</span>
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
</div>
</div>
<div v-if="config.showDivider" class="divider"></div>
</template>
</div>
<!-- 右切换按钮 -->
<div
class="nav-arrow right"
:class="{ disabled: isCurrentMonth }"
@click.stop="!isCurrentMonth && changeMonth(1)"
>
<van-icon name="arrow" />
</div>
<!-- 非本月时显示的日期标识 -->
<div v-if="!isCurrentMonth" class="date-tag">
{{ props.date.getFullYear() }}{{ props.date.getMonth() + 1 }}
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
stats: {
type: Object,
required: true
},
title: {
type: String,
required: true
},
getValueClass: {
type: Function,
required: true
},
date: {
type: Date,
default: () => new Date()
}
})
const emit = defineEmits(['update:date'])
const transitionName = ref('slide-right')
const dateKey = computed(() => props.date.getFullYear() + '-' + props.date.getMonth())
const isCurrentMonth = computed(() => {
const now = new Date()
return props.date.getFullYear() === now.getFullYear() &&
props.date.getMonth() === now.getMonth()
})
const periodConfigs = computed(() => ({
month: {
label: isCurrentMonth.value ? '本月' : `${props.date.getMonth() + 1}`,
showDivider: true
},
year: {
label: isCurrentMonth.value ? '年度' : `${props.date.getFullYear()}`,
showDivider: false
}
}))
const changeMonth = (delta) => {
transitionName.value = delta > 0 ? 'slide-left' : 'slide-right'
const newDate = new Date(props.date)
newDate.setMonth(newDate.getMonth() + delta)
emit('update:date', newDate)
}
const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })
}
</script>
<style scoped>
.summary-container {
margin-top: 12px;
position: relative;
}
.summary-card {
position: relative;
display: flex;
align-items: center;
padding: 16px 36px;
margin: 0 12px 8px;
min-height: 80px;
}
.summary-content {
flex: 1;
display: flex;
justify-content: space-around;
align-items: center;
text-align: center;
}
.nav-arrow {
position: absolute;
top: 0;
bottom: 0;
width: 40px;
display: flex;
justify-content: center;
align-items: center;
font-size: 18px;
color: #c8c9cc;
cursor: pointer;
transition: all 0.2s;
z-index: 1;
}
.nav-arrow:active {
color: var(--van-primary-color);
background-color: rgba(0, 0, 0, 0.02);
}
.nav-arrow.left {
left: 0;
}
.nav-arrow.right {
right: 0;
}
.nav-arrow.disabled {
color: #f2f3f5;
cursor: not-allowed;
}
.date-tag {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--van-primary-color);
background-color: var(--van-primary-color-light);
padding: 1px 8px;
border-radius: 0 0 8px 8px;
font-weight: 500;
opacity: 0.8;
}
/* 动画效果 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(30px);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(30px);
}
.summary-item {
flex: 1;
}
.summary-item .label {
font-size: 12px;
color: #969799;
margin-bottom: 6px;
}
.summary-item .value {
font-size: 20px;
font-weight: bold;
color: #323233;
}
.summary-item :deep(.value.expense) {
color: #ee0a24;
}
.summary-item :deep(.value.income) {
color: #07c160;
}
.summary-item :deep(.value.warning) {
color: #ff976a;
}
.summary-item .unit {
font-size: 11px;
margin-left: 1px;
font-weight: normal;
}
.summary-item .sub-info {
font-size: 12px;
color: #c8c9cc;
display: flex;
justify-content: center;
align-items: center;
gap: 3px;
}
.summary-item .amount {
color: #646566;
}
.summary-item .separator {
color: #c8c9cc;
}
.divider {
width: 1px;
height: 24px;
background-color: #ebedf0;
margin: 0 4px;
}
@media (prefers-color-scheme: dark) {
.nav-arrow:active {
background-color: rgba(255, 255, 255, 0.05);
}
.nav-arrow.disabled {
color: #323233;
}
.summary-item .value {
color: #f5f5f5;
}
.summary-item .amount {
color: #c8c9cc;
}
.divider {
background-color: #2c2c2c;
}
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<PopupContainer
v-model="visible"
title="设置存款分类"
height="60%"
>
<div class="savings-config-content">
<div class="config-header">
<p class="subtitle">这些分类的统计值将计入存款</p>
</div>
<div class="category-section">
<div class="section-title">可多选分类</div>
<ClassifySelector
v-model="selectedCategories"
:type="2"
multiple
:show-add="false"
:show-clear="false"
/>
</div>
</div>
<template #footer>
<van-button block round type="primary" @click="onSubmit">保存配置</van-button>
</template>
</PopupContainer>
</template>
<script setup>
import { ref } from 'vue'
import { showToast, showLoadingToast, closeToast } from 'vant'
import { getConfig, setConfig } from '@/api/config'
import PopupContainer from '@/components/PopupContainer.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
const emit = defineEmits(['success'])
const visible = ref(false)
const selectedCategories = ref([])
const open = async () => {
visible.value = true
await fetchConfig()
}
defineExpose({
open
})
const fetchConfig = async () => {
try {
const res = await getConfig('SavingsCategories')
if (res.success && res.data) {
selectedCategories.value = res.data.split(',').filter(x => x)
} else {
selectedCategories.value = []
}
} catch (err) {
console.error('获取配置失败', err)
}
}
const onSubmit = async () => {
showLoadingToast({ message: '保存中...', forbidClick: true })
try {
const value = selectedCategories.value.join(',')
const res = await setConfig('SavingsCategories', value)
if (res.success) {
showToast('配置已保存')
visible.value = false
emit('success')
}
} catch (err) {
console.error('保存配置失败', err)
showToast('保存失败')
} finally {
closeToast()
}
}
</script>
<style scoped>
.savings-config-content {
padding: 16px;
}
.config-header {
margin-bottom: 20px;
}
.subtitle {
font-size: 14px;
color: #969799;
margin: 0;
}
.section-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 12px;
}
.no-data {
text-align: center;
color: #969799;
width: 100%;
padding: 20px 0;
}
</style>

View File

@@ -1,169 +0,0 @@
<template>
<!-- 分类选择器弹窗 -->
<van-popup v-model:show="visible" position="bottom" round>
<van-picker
ref="pickerRef"
:columns="classifyColumns"
@confirm="onConfirm"
@cancel="onCancel"
>
<template #toolbar>
<div class="picker-toolbar">
<van-button class="toolbar-cancel" size="small" @click="onClear">清空</van-button>
<van-button class="toolbar-add" size="small" type="primary" @click="showAddDialog = true">新增</van-button>
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmSelect">确认</van-button>
</div>
</template>
</van-picker>
</van-popup>
<!-- 新增分类对话框 -->
<van-dialog
v-model:show="showAddDialog"
title="新增交易分类"
show-cancel-button
@confirm="addNewClassify"
>
<van-field v-model="newClassifyName" placeholder="请输入新的交易分类" />
</van-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { showToast } from 'vant'
import { getCategoryList, createCategory } from '@/api/transactionCategory'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 当前选中的分类
selectedClassify: {
type: String,
default: ''
},
// 交易类型(用于新增分类时传递)
transactionType: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update:modelValue', 'confirm', 'clear'])
const visible = ref(props.modelValue)
const pickerRef = ref(null)
const classifyColumns = ref([])
const showAddDialog = ref(false)
const newClassifyName = ref('')
// 监听外部显示状态变化
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
loadClassifyList()
}
})
// 监听内部显示状态变化
watch(visible, (val) => {
emit('update:modelValue', val)
})
// 加载分类列表
const loadClassifyList = async () => {
try {
const response = await getCategoryList()
if (response.success) {
classifyColumns.value = (response.data || []).map(item => ({
text: item.name,
value: item.name,
id: item.id
}))
}
} catch (error) {
console.error('加载分类列表出错:', error)
}
}
// 确认选择
const onConfirm = ({ selectedOptions }) => {
if (selectedOptions && selectedOptions[0]) {
emit('confirm', selectedOptions[0].text)
}
visible.value = false
}
// 取消
const onCancel = () => {
visible.value = false
}
// 清空分类
const onClear = () => {
emit('clear')
visible.value = false
showToast('已清空分类')
}
// 确认选择(从 picker 中获取选中值)
const confirmSelect = () => {
if (pickerRef.value) {
const selectedValues = pickerRef.value.getSelectedOptions()
if (selectedValues && selectedValues[0]) {
emit('confirm', selectedValues[0].text)
}
}
visible.value = false
}
// 新增分类
const addNewClassify = async () => {
if (!newClassifyName.value.trim()) {
showToast('请输入分类名称')
return
}
try {
const response = await createCategory({
name: newClassifyName.value.trim(),
type: props.transactionType
})
if (response.success) {
showToast('新增分类成功')
const newClassify = response.data.name
newClassifyName.value = ''
// 重新加载分类列表
await loadClassifyList()
// 自动选中新增的分类
emit('confirm', newClassify)
visible.value = false
} else {
showToast(response.message || '新增分类失败')
}
} catch (error) {
console.error('新增分类失败:', error)
showToast('新增分类失败')
}
}
</script>
<style scoped>
.picker-toolbar {
display: flex;
width: 100%;
align-items: center;
padding: 5px 10px;
border-bottom: 1px solid var(--van-border-color);
}
.toolbar-cancel {
margin-right: auto;
}
.toolbar-confirm {
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,249 @@
<template>
<div class="classify-selector">
<div class="classify-buttons">
<!-- 全选按钮 (仅多选模式) -->
<van-button
v-if="multiple && showAll"
:type="isAllSelected ? 'primary' : 'default'"
size="small"
class="classify-btn all-btn"
@click="toggleAll"
>
{{ isAllSelected ? '取消全选' : '全选' }}
</van-button>
<!-- 新增按钮 -->
<van-button
v-if="showAdd"
type="success"
size="small"
class="classify-btn"
@click="openAddDialog"
>
+ 新增
</van-button>
<!-- 分类项按钮 -->
<van-button
v-for="item in displayOptions"
:key="item.id || item.text"
:type="isSelected(item) ? 'primary' : 'default'"
size="small"
class="classify-btn"
@click="toggleItem(item)"
>
{{ item.text }}
</van-button>
<!-- 清空按钮 -->
<van-button
v-if="showClear && hasSelection"
type="warning"
size="small"
class="classify-btn"
@click="clear"
>
清空
</van-button>
</div>
<!-- 新增分类对话框 -->
<AddClassifyDialog
ref="addClassifyDialogRef"
@confirm="handleAddConfirm"
/>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { showToast } from 'vant'
import { getCategoryList, createCategory } from '@/api/transactionCategory'
import AddClassifyDialog from './AddClassifyDialog.vue'
const props = defineProps({
modelValue: {
type: [String, Array],
default: ''
},
options: {
type: Array,
default: null
},
type: {
type: [Number, String],
default: null
},
multiple: {
type: Boolean,
default: false
},
showAdd: {
type: Boolean,
default: true
},
showClear: {
type: Boolean,
default: true
},
showAll: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:modelValue', 'add', 'change'])
const innerOptions = ref([])
const addClassifyDialogRef = ref()
const displayOptions = computed(() => {
if (props.options) return props.options
return innerOptions.value
})
const fetchOptions = async () => {
if (props.options) return
try {
const response = await getCategoryList(props.type)
if (response.success) {
innerOptions.value = (response.data || []).map(item => ({
text: item.name,
value: item.name,
id: item.id
}))
}
} catch (error) {
console.error('ClassifySelector 加载分类失败:', error)
}
}
// 打开新增对话框
const openAddDialog = () => {
addClassifyDialogRef.value?.open()
}
// 处理新增确认
const handleAddConfirm = async (categoryName) => {
try {
// 调用API创建分类
const response = await createCategory({
name: categoryName,
type: props.type
})
if (response.success) {
showToast('分类创建成功')
// 刷新列表
await fetchOptions()
// 如果是单选模式,且当前没有选值或就是为了新增,则自动选中
if (!props.multiple) {
emit('update:modelValue', categoryName)
emit('change', categoryName)
}
} else {
showToast(response.message || '创建分类失败')
}
} catch (error) {
console.error('ClassifySelector 创建分类出错:', error)
showToast('创建分类失败')
}
}
watch(() => props.type, () => {
fetchOptions()
})
onMounted(() => {
fetchOptions()
})
// 公开刷新方法
defineExpose({
refresh: fetchOptions
})
// 是否选中
const isSelected = (item) => {
if (props.multiple) {
return Array.isArray(props.modelValue) && props.modelValue.includes(item.text)
}
return props.modelValue === item.text
}
// 是否全部选中
const isAllSelected = computed(() => {
if (!props.multiple || displayOptions.value.length === 0) return false
return displayOptions.value.every(item => props.modelValue.includes(item.text))
})
// 是否有任何选中
const hasSelection = computed(() => {
if (props.multiple) {
return Array.isArray(props.modelValue) && props.modelValue.length > 0
}
return !!props.modelValue
})
// 切换选中状态
const toggleItem = (item) => {
if (props.multiple) {
const newValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const index = newValue.indexOf(item.text)
if (index > -1) {
newValue.splice(index, 1)
} else {
newValue.push(item.text)
}
emit('update:modelValue', newValue)
emit('change', newValue)
} else {
const newValue = props.modelValue === item.text ? '' : item.text
emit('update:modelValue', newValue)
emit('change', newValue)
}
}
// 切换全选
const toggleAll = () => {
if (!props.multiple) return
if (isAllSelected.value) {
emit('update:modelValue', [])
emit('change', [])
} else {
const allValues = displayOptions.value.map(item => item.text)
emit('update:modelValue', allValues)
emit('change', allValues)
}
}
// 清空
const clear = () => {
const newValue = props.multiple ? [] : ''
emit('update:modelValue', newValue)
emit('change', newValue)
}
</script>
<style scoped>
.classify-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
}
.classify-btn {
flex: 0 0 auto;
min-width: 70px;
border-radius: 16px;
}
.all-btn {
font-weight: bold;
border-style: dashed;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="global-add-bill">
<!-- Floating Add Bill Button -->
<div class="floating-add" @click="openAddBill">
<van-icon name="plus" />
</div>
<!-- Add Bill Modal -->
<PopupContainer
v-model="showAddBill"
title="记一笔"
height="75%"
>
<van-tabs v-model:active="activeTab" shrink>
<van-tab title="一句话录账" name="one">
<OneLineBillAdd :key="componentKey" @success="handleSuccess" />
</van-tab>
<van-tab title="手动录账" name="manual">
<ManualBillAdd :key="componentKey" @success="handleSuccess" />
</van-tab>
</van-tabs>
</PopupContainer>
</div>
</template>
<script setup>
import { ref, defineEmits } from 'vue'
import PopupContainer from '@/components/PopupContainer.vue'
import OneLineBillAdd from '@/components/Bill/OneLineBillAdd.vue'
import ManualBillAdd from '@/components/Bill/ManualBillAdd.vue'
const emit = defineEmits(['success'])
const showAddBill = ref(false)
const activeTab = ref('one')
const componentKey = ref(0)
const openAddBill = () => {
showAddBill.value = true
// 清理状态,默认选中一句话录账
activeTab.value = 'one'
// 清理子组件状态通过 key 强制重渲染
componentKey.value++
}
const handleSuccess = () => {
showAddBill.value = false
emit('success')
}
</script>
<style scoped>
.floating-add {
position: fixed;
bottom: 95px; /* Above tabbar */
right: 20px;
width: 50px;
height: 50px;
background-color: var(--van-primary-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 999;
cursor: pointer;
transition: transform 0.2s;
}
.floating-add:active {
transform: scale(0.9);
}
:deep(.van-tabs__wrap) {
position: sticky;
top: 0;
z-index: 10;
background-color: #fff;
}
</style>

View File

@@ -1,21 +1,30 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<template>
<van-popup
v-model:show="visible"
position="bottom"
:style="{ height: height }"
round
:closeable="closeable"
teleport="body"
>
<div class="popup-container">
<!-- 头部区域 -->
<div class="popup-header-fixed">
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
<div class="header-title-row" :class="{ 'has-actions': !subtitle && hasActions }">
<h3 class="popup-title">{{ title }}</h3>
<!-- 无子标题时操作按钮与标题同行 -->
<div v-if="!subtitle && hasActions" class="header-actions-inline">
<slot name="header-actions"></slot>
</div>
</div>
<!-- 子标题/统计信息 -->
<div v-if="subtitle || hasActions" class="header-stats">
<span v-if="subtitle" class="stats-text" v-html="subtitle" />
<div v-if="subtitle" class="header-stats">
<span class="stats-text" v-html="subtitle" />
<!-- 额外操作插槽 -->
<slot name="header-actions"></slot>
<slot v-if="hasActions" name="header-actions"></slot>
</div>
</div>
@@ -23,6 +32,11 @@
<div class="popup-scroll-content">
<slot></slot>
</div>
<!-- 底部页脚固定不可滚动 -->
<div v-if="slots.footer" class="popup-footer-fixed">
<slot name="footer"></slot>
</div>
</div>
</van-popup>
</template>
@@ -33,24 +47,24 @@ import { computed, useSlots } from 'vue'
const props = defineProps({
modelValue: {
type: Boolean,
required: true
required: true,
},
title: {
type: String,
default: ''
default: '',
},
subtitle: {
type: String,
default: ''
default: '',
},
height: {
type: String,
default: '80%'
default: '80%',
},
closeable: {
type: Boolean,
default: true
}
default: true,
},
})
const emit = defineEmits(['update:modelValue'])
@@ -60,7 +74,7 @@ const slots = useSlots()
// 双向绑定
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
set: (value) => emit('update:modelValue', value),
})
// 判断是否有操作按钮
@@ -84,6 +98,24 @@ const hasActions = computed(() => !!slots['header-actions'])
z-index: 10;
}
.header-title-row.has-actions {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
.header-title-row.has-actions .popup-title {
grid-column: 2;
min-width: 0;
}
.header-actions-inline {
grid-column: 3;
justify-self: end;
display: flex;
align-items: center;
}
.popup-title {
font-size: 16px;
font-weight: 500;
@@ -94,7 +126,6 @@ const hasActions = computed(() => !!slots['header-actions'])
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-stats {
@@ -125,4 +156,11 @@ const hasActions = computed(() => !!slots['header-actions'])
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.popup-footer-fixed {
flex-shrink: 0;
border-top: 1px solid var(--van-border-color, #ebedf0);
background-color: var(--van-background-2, #f7f8fa);
padding: 12px 16px;
}
</style>

View File

@@ -40,7 +40,7 @@
{{ group.sampleClassify }}
</van-tag>
<span class="count-text">{{ group.count }} </span>
<span class="amount-text" v-if="group.totalAmount">
<span v-if="group.totalAmount" class="amount-text">
¥{{ Math.abs(group.totalAmount).toFixed(2) }}
</span>
</div>
@@ -56,7 +56,7 @@
v-model="showTransactionList"
:title="selectedGroup?.reason || '交易记录'"
:subtitle="groupTransactionsTotal ? `共 ${groupTransactionsTotal} 笔交易` : ''"
height="80%"
height="75%"
>
<template #header-actions>
<van-button
@@ -80,19 +80,17 @@
</PopupContainer>
<!-- 账单详情弹窗 -->
<TransactionDetailDialog
v-model="showTransactionDetail"
<TransactionDetail
v-model:show="showTransactionDetail"
:transaction="selectedTransaction"
@saved="handleTransactionSaved"
@save="handleTransactionSaved"
/>
<!-- 批量设置对话框 -->
<van-dialog
v-model:show="showBatchDialog"
<PopupContainer
v-model="showBatchDialog"
title="批量设置分类"
:show-cancel-button="true"
@confirm="handleConfirmBatchUpdate"
@cancel="resetBatchForm"
height="60%"
>
<van-form ref="batchFormRef" class="setting-form">
<van-cell-group inset>
@@ -112,17 +110,16 @@
input-align="left"
/>
<!-- 交易类型选择 -->
<van-field
v-model="batchForm.typeName"
is-link
readonly
name="type"
label="交易类型"
placeholder="请选择交易类型"
@click="showTypePicker = true"
:rules="[{ required: true, message: '请选择交易类型' }]"
/>
<!-- 交易类型 -->
<van-field name="type" label="交易类型">
<template #input>
<van-radio-group v-model="batchForm.type" direction="horizontal">
<van-radio :name="0">支出</van-radio>
<van-radio :name="1">收入</van-radio>
<van-radio :name="2">不计</van-radio>
</van-radio-group>
</template>
</van-field>
<!-- 分类选择 -->
<van-field name="classify" label="分类">
@@ -132,59 +129,24 @@
</template>
</van-field>
<!-- 分类按钮网格 -->
<div class="classify-buttons">
<van-button
v-for="item in classifyOptions"
:key="item.id"
:type="batchForm.classify === item.text ? 'primary' : 'default'"
size="small"
class="classify-btn"
@click="selectClassify(item.text)"
>
{{ item.text }}
</van-button>
<van-button
type="success"
size="small"
class="classify-btn"
@click="showAddClassify = true"
>
+ 新增
</van-button>
<van-button
v-if="batchForm.classify"
type="warning"
size="small"
class="classify-btn"
@click="clearClassify"
>
清空
</van-button>
</div>
<!-- 分类选择组件 -->
<ClassifySelector
v-model="batchForm.classify"
:type="batchForm.type"
/>
</van-cell-group>
</van-form>
</van-dialog>
<!-- 交易类型选择器 -->
<van-popup v-model:show="showTypePicker" position="bottom" round>
<van-picker
show-toolbar
:columns="typeOptions"
@confirm="handleConfirmType"
@cancel="showTypePicker = false"
/>
</van-popup>
<!-- 新增分类对话框 -->
<van-dialog
v-model:show="showAddClassify"
title="新增交易分类"
show-cancel-button
@confirm="addNewClassify"
<template #footer>
<van-button
round
block
type="primary"
@click="handleConfirmBatchUpdate"
>
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
</van-dialog>
确定
</van-button>
</template>
</PopupContainer>
</div>
</template>
@@ -198,9 +160,9 @@ import {
showConfirmDialog
} from 'vant'
import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord'
import { getCategoryList, createCategory } from '@/api/transactionCategory'
import ClassifySelector from './ClassifySelector.vue'
import TransactionList from './TransactionList.vue'
import TransactionDetailDialog from './TransactionDetailDialog.vue'
import TransactionDetail from './TransactionDetail.vue'
import PopupContainer from './PopupContainer.vue'
const props = defineProps({
@@ -212,19 +174,12 @@ const props = defineProps({
// 每页数量
pageSize: {
type: Number,
default: 3 // TODO 测试写小一点
default: 20
}
})
const emit = defineEmits(['long-press', 'data-loaded', 'data-changed'])
// 交易类型选项
const typeOptions = [
{ text: '支出', value: 0 },
{ text: '收入', value: 1 },
{ text: '不计收支', value: 2 }
]
// 数据状态
const groups = ref([])
const loading = ref(false)
@@ -232,8 +187,6 @@ const selectedReasons = ref(new Set())
const pageIndex = ref(1)
const finished = ref(false)
const total = ref(0)
const categories = ref([])
// 弹窗状态
const showTransactionList = ref(false)
const showTransactionDetail = ref(false)
@@ -250,31 +203,17 @@ const transactionPageSize = ref(20)
// 批量分类相关状态
const showBatchDialog = ref(false)
const showTypePicker = ref(false)
const showAddClassify = ref(false)
const batchFormRef = ref(null)
const batchGroup = ref(null)
const newClassify = ref('')
const batchForm = ref({
type: null,
typeName: '',
classify: ''
})
// 根据选中的类型过滤分类选项
const classifyOptions = computed(() => {
if (batchForm.value.type === null) return []
return categories.value
.filter(c => c.type === batchForm.value.type)
.map(c => ({ text: c.name, value: c.name, id: c.id }))
})
// 监听交易类型变化,重新加载分类
watch(() => batchForm.value.type, (newVal) => {
batchForm.value.classify = ''
if (newVal !== null) {
loadCategories(newVal)
}
})
// 获取类型名称
@@ -376,79 +315,9 @@ const handleBatchClassify = (group) => {
typeName: getTypeName(group.sampleType),
classify: group.sampleClassify || ''
}
// 加载对应类型的分类列表
loadCategories(group.sampleType)
showBatchDialog.value = true
}
// 获取所有分类
const loadCategories = async (type = null) => {
try {
const res = await getCategoryList(type)
if (res.success) {
categories.value = res.data || []
}
} catch (error) {
console.error('获取分类列表失败:', error)
}
}
// 选择分类
const selectClassify = (classify) => {
batchForm.value.classify = classify
}
// 确认选择交易类型
const handleConfirmType = ({ selectedOptions }) => {
batchForm.value.type = selectedOptions[0].value
batchForm.value.typeName = selectedOptions[0].text
showTypePicker.value = false
}
// 新增分类
const addNewClassify = async () => {
if (!newClassify.value.trim()) {
showToast('请输入分类名称')
return
}
if (batchForm.value.type === null) {
showToast('请先选择交易类型')
return
}
try {
const categoryName = newClassify.value.trim()
// 调用API创建分类
const response = await createCategory({
name: categoryName,
type: batchForm.value.type
})
if (response.success) {
showToast('分类创建成功')
// 重新加载分类列表
await loadCategories(batchForm.value.type)
batchForm.value.classify = categoryName
} else {
showToast(response.message || '创建分类失败')
}
} catch (error) {
console.error('创建分类出错:', error)
showToast('创建分类失败')
} finally {
newClassify.value = ''
showAddClassify.value = false
}
}
// 清空分类
const clearClassify = () => {
batchForm.value.classify = ''
showToast('已清空分类')
}
// 确认批量更新
const handleConfirmBatchUpdate = async () => {
try {
@@ -571,7 +440,7 @@ onBeforeUnmount(() => {
})
// 当有交易新增/修改/批量更新时的刷新监听
const onGlobalTransactionsChanged = (e) => {
const onGlobalTransactionsChanged = () => {
if (showTransactionList.value && selectedGroup.value) {
groupTransactions.value = []
transactionPageIndex.value = 1
@@ -737,6 +606,17 @@ defineExpose({
height: 28px;
}
.popup-actions {
display: flex;
gap: 8px;
align-items: center;
}
.popup-actions .van-button {
flex: 1;
min-width: auto;
}
.group-info {
display: flex;
align-items: center;
@@ -765,20 +645,5 @@ defineExpose({
padding: 16px 0;
}
.classify-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
max-height: 300px;
overflow-y: auto;
}
.classify-btn {
flex: 0 0 auto;
min-width: 70px;
border-radius: 16px;
}
/* 交易列表弹窗 - 自定义样式 */
</style>

View File

@@ -4,17 +4,15 @@
:type="buttonType"
size="small"
:loading="loading || saving"
:loading-text="loadingText"
:disabled="loading || saving"
@click="handleClick"
class="smart-classify-btn"
@click="handleClick"
>
<template v-if="!loading && !saving">
<van-icon :name="buttonIcon" />
<span style="margin-left: 4px;">{{ buttonText }}</span>
</template>
<template v-else>
<span>{{ loadingText }}</span>
</template>
</van-button>
</template>
@@ -39,6 +37,7 @@ const emit = defineEmits(['update', 'save', 'notifyDonedTransactionId'])
const loading = ref(false)
const saving = ref(false)
const classifiedResults = ref([])
const lockClassifiedResults = ref(false)
const isAllCompleted = ref(false)
let toastInstance = null
@@ -47,7 +46,8 @@ const hasTransactions = computed(() => {
})
const hasClassifiedResults = computed(() => {
return isAllCompleted.value && classifiedResults.value.length > 0
// Show save state once we have any classified result, even if not all batches finished
return classifiedResults.value.length > 0 && lockClassifiedResults.value === false
})
// 按钮类型
@@ -92,6 +92,8 @@ const handleClick = () => {
* 保存分类结果
*/
const handleSaveClassify = async () => {
if (saving.value || loading.value) return
try {
saving.value = true
showToast({
@@ -145,12 +147,23 @@ const handleSaveClassify = async () => {
}
}
/**
* 处理智能分类
*/
const handleSmartClassify = async () => {
if (loading.value || saving.value) {
showToast('当前有任务正在进行,请稍后再试')
return
}
loading.value = true
if (!props.transactions || props.transactions.length === 0) {
showToast('没有可分类的交易记录')
loading.value = false
return
}
if(lockClassifiedResults.value) {
showToast('当前有分类任务正在进行,请稍后再试')
loading.value = false
return
}
@@ -158,17 +171,12 @@ const handleSmartClassify = async () => {
isAllCompleted.value = false
classifiedResults.value = []
const batchSize = 30
const batchSize = 3
let processedCount = 0
try {
loading.value = true
// 清除之前的Toast
if (toastInstance) {
closeToast()
}
// 等待父组件的 beforeClassify 事件处理完成(如果有返回 Promise TODO 没有生效
lockClassifiedResults.value = true
// 等待父组件的 beforeClassify 事件处理完成(如果有返回 Promise
if (props.onBeforeClassify) {
const shouldContinue = await props.onBeforeClassify()
if (shouldContinue === false) {
@@ -323,6 +331,7 @@ const handleSmartClassify = async () => {
})
} finally {
loading.value = false
lockClassifiedResults.value = false
// 确保Toast被清除
if (toastInstance) {
setTimeout(() => {
@@ -333,10 +342,20 @@ const handleSmartClassify = async () => {
}
}
const removeClassifiedTransaction = (transactionId) => {
// 从已分类结果中移除指定ID的项
classifiedResults.value = classifiedResults.value.filter(item => item.id !== transactionId)
}
/**
* 重置组件状态
*/
const reset = () => {
if(lockClassifiedResults.value) {
showToast('当前有分类任务正在进行,无法重置')
return
}
isAllCompleted.value = false
classifiedResults.value = []
loading.value = false
@@ -344,7 +363,8 @@ const reset = () => {
}
defineExpose({
reset
reset,
removeClassifiedTransaction
});
</script>

View File

@@ -1,26 +1,30 @@
<template>
<van-popup
v-model:show="visible"
position="bottom"
:style="{ height: '85%' }"
round
closeable
@update:show="handleVisibleChange"
<PopupContainer
v-model="visible"
title="交易详情"
height="75%"
:closeable="false"
>
<div class="popup-container" v-if="transaction">
<div class="popup-header-fixed">
<h3>交易详情</h3>
</div>
<template #header-actions>
<van-button size="small" type="primary" plain @click="handleOffsetClick">抵账</van-button>
</template>
<div class="popup-scroll-content">
<van-form @submit="onSubmit" style="margin-top: 12px;">
<van-form style="margin-top: 12px;">
<van-cell-group inset>
<van-cell title="卡号" :value="transaction.card" />
<van-cell title="交易时间" :value="formatDate(transaction.occurredAt)" />
<van-cell title="记录时间" :value="formatDate(transaction.createTime)" />
</van-cell-group>
<van-cell-group inset title="交易明细">
<van-field
v-model="occurredAtLabel"
name="occurredAt"
label="交易时间"
readonly
is-link
placeholder="请选择交易时间"
:rules="[{ required: true, message: '请选择交易时间' }]"
@click="showDatePicker = true"
/>
<van-field
v-model="editForm.reason"
name="reason"
@@ -48,91 +52,109 @@
type="number"
:rules="[{ required: true, message: '请输入交易后余额' }]"
/>
<van-field
v-model="editForm.typeText"
is-link
readonly
name="type"
label="交易类型"
placeholder="请选择交易类型"
@click="showTypePicker = true"
:rules="[{ required: true, message: '请选择交易类型' }]"
/>
<van-field name="classify" label="交易分类">
<van-field name="type" label="交易类型">
<template #input>
<span v-if="!editForm.classify" style="color: #c8c9cc;">请选择交易分类</span>
<span v-else>{{ editForm.classify }}</span>
<van-radio-group v-model="editForm.type" direction="horizontal" @change="handleTypeChange">
<van-radio :name="0">支出</van-radio>
<van-radio :name="1">收入</van-radio>
<van-radio :name="2">不计</van-radio>
</van-radio-group>
</template>
</van-field>
<!-- 分类按钮网格 -->
<div class="classify-buttons">
<van-button
v-for="item in classifyColumns"
:key="item.id"
:type="editForm.classify === item.text ? 'primary' : 'default'"
size="small"
class="classify-btn"
@click="selectClassify(item.text)"
<van-field name="classify" label="交易分类">
<template #input>
<div style="flex: 1;">
<div
v-if="transaction && transaction.unconfirmedClassify && transaction.unconfirmedClassify !== editForm.classify"
class="suggestion-tip"
@click="applySuggestion"
>
{{ item.text }}
</van-button>
<van-button
type="success"
size="small"
class="classify-btn"
@click="showAddClassify = true"
>
+ 新增
</van-button>
<van-button
v-if="editForm.classify"
type="warning"
size="small"
class="classify-btn"
@click="clearClassify"
>
清空
</van-button>
<van-icon name="bulb-o" class="suggestion-icon" />
<span class="suggestion-text">
建议: {{ transaction.unconfirmedClassify }}
<span v-if="transaction.unconfirmedType !== null && transaction.unconfirmedType !== undefined && transaction.unconfirmedType !== editForm.type">
({{ getTypeName(transaction.unconfirmedType) }})
</span>
</span>
<div class="suggestion-apply">应用</div>
</div>
</van-cell-group>
<span v-else-if="!editForm.classify" style="color: #c8c9cc;">请选择交易分类</span>
<span v-else>{{ editForm.classify }}</span>
</div>
</template>
</van-field>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit" :loading="submitting">
<ClassifySelector
v-model="editForm.classify"
:type="editForm.type"
@change="handleClassifyChange"
/>
</van-cell-group>
</van-form>
<template #footer>
<van-button
round
block
type="primary"
:loading="submitting"
@click="onSubmit"
>
保存修改
</van-button>
</div>
</van-form>
</div>
</div>
</van-popup>
</template>
</PopupContainer>
<!-- 交易类型选择器 -->
<van-popup v-model:show="showTypePicker" position="bottom" round>
<van-picker
show-toolbar
:columns="typeColumns"
@confirm="onTypeConfirm"
@cancel="showTypePicker = false"
<!-- 抵账候选列表弹窗 -->
<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-date-picker
v-model="currentDate"
title="选择日期"
@confirm="onConfirmDate"
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 新增分类对话框 -->
<van-dialog
v-model:show="showAddClassify"
title="新增交易分类"
show-cancel-button
@confirm="addNewClassify"
>
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
</van-dialog>
<!-- 时间选择弹窗 -->
<van-popup v-model:show="showTimePicker" position="bottom" round teleport="body">
<van-time-picker
v-model="currentTime"
title="选择时间"
@confirm="onConfirmTime"
@cancel="showTimePicker = false"
/>
</van-popup>
</template>
<script setup>
import { ref, reactive, watch, defineProps, defineEmits } from 'vue'
import { showToast } from 'vant'
import { updateTransaction } from '@/api/transactionRecord'
import { getCategoryList, createCategory } from '@/api/transactionCategory'
import { ref, reactive, watch, defineProps, defineEmits, computed, nextTick } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
import dayjs from 'dayjs'
import PopupContainer from '@/components/PopupContainer.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import { updateTransaction, getCandidatesForOffset, offsetTransactions } from '@/api/transactionRecord'
const props = defineProps({
show: {
@@ -149,19 +171,13 @@ const emit = defineEmits(['update:show', 'save'])
const visible = ref(false)
const submitting = ref(false)
const isSyncing = ref(false)
// 交易类型
const typeColumns = [
{ text: '支出', value: 0 },
{ text: '收入', value: 1 },
{ text: '不计入收支', value: 2 }
]
// 分类相关
const classifyColumns = ref([])
const showTypePicker = ref(false)
const showAddClassify = ref(false)
const newClassify = ref('')
// 日期选择相关
const showDatePicker = ref(false)
const showTimePicker = ref(false)
const currentDate = ref([])
const currentTime = ref([])
// 编辑表单
const editForm = reactive({
@@ -170,8 +186,13 @@ const editForm = reactive({
amount: '',
balance: '',
type: 0,
typeText: '',
classify: ''
classify: '',
occurredAt: ''
})
// 显示用的日期格式化
const occurredAtLabel = computed(() => {
return formatDate(editForm.occurredAt)
})
// 监听props变化
@@ -181,17 +202,27 @@ watch(() => props.show, (newVal) => {
watch(() => props.transaction, (newVal) => {
if (newVal) {
isSyncing.value = true
// 填充编辑表单
editForm.id = newVal.id
editForm.reason = newVal.reason || ''
editForm.amount = String(newVal.amount)
editForm.balance = String(newVal.balance)
editForm.type = newVal.type
editForm.typeText = getTypeName(newVal.type)
editForm.classify = newVal.classify || ''
// 根据交易类型加载分类
loadClassifyList(newVal.type)
// 初始化日期时间
if (newVal.occurredAt) {
editForm.occurredAt = newVal.occurredAt
const dt = dayjs(newVal.occurredAt)
currentDate.value = dt.format('YYYY-MM-DD').split('-')
currentTime.value = dt.format('HH:mm').split(':')
}
// 在下一个 tick 结束同步状态,确保 van-radio-group 的 @change 已触发完毕
nextTick(() => {
isSyncing.value = false
})
}
})
@@ -199,32 +230,47 @@ watch(visible, (newVal) => {
emit('update:show', newVal)
})
// 监听交易类型变化,重新加载分类
watch(() => editForm.type, (newVal) => {
// 清空已选的分类
// 处理类型切换
const handleTypeChange = () => {
if (!isSyncing.value) {
editForm.classify = ''
// 重新加载对应类型的分类列表
loadClassifyList(newVal)
})
const handleVisibleChange = (newVal) => {
emit('update:show', newVal)
}
}
// 加载分类列表
const loadClassifyList = async (type = null) => {
try {
const response = await getCategoryList(type)
if (response.success) {
classifyColumns.value = (response.data || []).map(item => ({
text: item.name,
value: item.name,
id: item.id
}))
// 处理日期确认
const onConfirmDate = ({ selectedValues }) => {
const dateStr = selectedValues.join('-')
const timeStr = currentTime.value.join(':')
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).toISOString()
showDatePicker.value = false
// 接着选时间
showTimePicker.value = true
}
} catch (error) {
console.error('加载分类列表出错:', error)
const onConfirmTime = ({ selectedValues }) => {
currentTime.value = selectedValues
const dateStr = currentDate.value.join('-')
const timeStr = selectedValues.join(':')
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).toISOString()
showTimePicker.value = false
}
const applySuggestion = () => {
if (props.transaction.unconfirmedClassify) {
editForm.classify = props.transaction.unconfirmedClassify
if (props.transaction.unconfirmedType !== null && props.transaction.unconfirmedType !== undefined) {
editForm.type = props.transaction.unconfirmedType
}
}
}
const getTypeName = (type) => {
const typeMap = {
0: '支出',
1: '收入',
2: '不计'
}
return typeMap[type] || '未知'
}
// 提交编辑
@@ -238,7 +284,8 @@ const onSubmit = async () => {
amount: parseFloat(editForm.amount),
balance: parseFloat(editForm.balance),
type: editForm.type,
classify: editForm.classify
classify: editForm.classify,
occurredAt: editForm.occurredAt
}
const response = await updateTransaction(data)
@@ -246,8 +293,6 @@ const onSubmit = async () => {
showToast('保存成功')
visible.value = false
emit('save', data)
// 重新加载分类列表
await loadClassifyList(editForm.type)
} else {
showToast(response.message || '保存失败')
}
@@ -259,68 +304,15 @@ const onSubmit = async () => {
}
}
// 选择分类
const selectClassify = (classify) => {
editForm.classify = classify
}
// 交易类型选择确认
const onTypeConfirm = ({ selectedValues, selectedOptions }) => {
editForm.type = selectedValues[0]
editForm.typeText = selectedOptions[0].text
showTypePicker.value = false
}
// 新增分类
const addNewClassify = async () => {
if (!newClassify.value.trim()) {
showToast('请输入分类名称')
return
}
try {
const categoryName = newClassify.value.trim()
// 调用API创建分类
const response = await createCategory({
name: categoryName,
type: editForm.type
})
if (response.success) {
showToast('分类创建成功')
// 重新加载分类列表
await loadClassifyList(editForm.type)
editForm.classify = categoryName
} else {
showToast(response.message || '创建分类失败')
}
} catch (error) {
console.error('创建分类出错:', error)
showToast('创建分类失败')
} finally {
newClassify.value = ''
showAddClassify.value = false
// 分类选择变化
const handleClassifyChange = () => {
if (editForm.id > 0 && editForm.type >= 0) {
// 直接保存
onSubmit()
}
}
// 清空分类
const clearClassify = () => {
editForm.classify = ''
showToast('已清空分类')
}
// 获取交易类型名称
const getTypeName = (type) => {
const typeMap = {
0: '支出',
1: '收入',
2: '不计入收支'
}
return typeMap[type] || '未知'
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
@@ -332,19 +324,101 @@ const formatDate = (dateString) => {
minute: '2-digit'
})
}
// 抵账相关
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>
<style scoped>
.classify-buttons {
.suggestion-tip {
font-size: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
align-items: center;
padding: 6px 10px;
background: #ecf9ff;
color: #1989fa;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.2s;
border: 1px solid rgba(25, 137, 250, 0.1);
width: fit-content;
}
.classify-btn {
flex: 0 0 auto;
min-width: 70px;
border-radius: 16px;
.suggestion-tip:active {
opacity: 0.7;
}
.suggestion-icon {
margin-right: 4px;
font-size: 14px;
}
.suggestion-text {
font-weight: 500;
}
.suggestion-apply {
margin-left: 8px;
padding: 0 6px;
background: #1989fa;
color: #fff;
border-radius: 4px;
font-size: 10px;
height: 18px;
line-height: 18px;
font-weight: bold;
}
@media (prefers-color-scheme: dark) {
.suggestion-tip {
background: rgba(25, 137, 250, 0.15);
border-color: rgba(25, 137, 250, 0.2);
color: #58a6ff;
}
.suggestion-apply {
background: #58a6ff;
}
}
</style>

View File

@@ -1,410 +0,0 @@
<template>
<van-popup
v-model:show="visible"
position="bottom"
:style="{ height: '70%' }"
round
closeable
@closed="handleClosed"
>
<div class="transaction-detail-dialog">
<div class="dialog-header">
<h3 class="dialog-title">账单详情</h3>
</div>
<div class="dialog-content">
<van-form ref="formRef">
<van-cell-group inset>
<!-- 交易摘要 -->
<van-field
:model-value="formData.reason"
label="交易摘要"
readonly
input-align="left"
/>
<!-- 交易时间 -->
<van-field
:model-value="formatDateTime(formData.occurredAt)"
label="交易时间"
readonly
input-align="left"
/>
<!-- 卡号 -->
<van-field
v-if="formData.card"
:model-value="formData.card"
label="卡号"
readonly
input-align="left"
/>
<!-- 交易金额 -->
<van-field
v-model.number="formData.amount"
label="交易金额"
type="number"
placeholder="请输入金额"
input-align="left"
:rules="[{ required: true, message: '请输入交易金额' }]"
/>
<!-- 交易后余额 -->
<van-field
v-model.number="formData.balance"
label="交易后余额"
type="number"
placeholder="请输入余额"
input-align="left"
/>
<!-- 交易类型 -->
<van-field
v-model="typeText"
is-link
readonly
label="交易类型"
placeholder="请选择交易类型"
@click="showTypePicker = true"
:rules="[{ required: true, message: '请选择交易类型' }]"
/>
<!-- 分类选择 -->
<van-field label="分类">
<template #input>
<span v-if="!formData.classify" style="opacity: 0.4;">请选择分类</span>
<span v-else>{{ formData.classify }}</span>
</template>
</van-field>
<!-- 分类按钮网格 -->
<div class="classify-buttons">
<van-button
v-for="item in classifyOptions"
:key="item.id"
:type="formData.classify === item.name ? 'primary' : 'default'"
size="small"
class="classify-btn"
@click="selectClassify(item.name)"
>
{{ item.name }}
</van-button>
<van-button
type="success"
size="small"
class="classify-btn"
@click="showAddClassify = true"
>
+ 新增
</van-button>
<van-button
v-if="formData.classify"
type="warning"
size="small"
class="classify-btn"
@click="clearClassify"
>
清空
</van-button>
</div>
</van-cell-group>
</van-form>
</div>
<div class="dialog-footer">
<van-button
type="primary"
block
:loading="saving"
@click="handleSave"
>
保存
</van-button>
</div>
</div>
<!-- 交易类型选择器 -->
<van-popup v-model:show="showTypePicker" position="bottom" round>
<van-picker
show-toolbar
:columns="typeOptions"
@confirm="handleConfirmType"
@cancel="showTypePicker = false"
/>
</van-popup>
<!-- 新增分类对话框 -->
<van-dialog
v-model:show="showAddClassify"
title="新增交易分类"
show-cancel-button
@confirm="addNewClassify"
>
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
</van-dialog>
</van-popup>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { showToast, showSuccessToast } from 'vant'
import { updateTransaction } from '@/api/transactionRecord'
import { getCategoryList, createCategory } from '@/api/transactionCategory'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
transaction: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = ref(props.modelValue)
const formRef = ref(null)
const saving = ref(false)
const showTypePicker = ref(false)
const showAddClassify = ref(false)
const newClassify = ref('')
const categories = ref([])
// 交易类型选项
const typeOptions = [
{ text: '支出', value: 0 },
{ text: '收入', value: 1 },
{ text: '不计收支', value: 2 }
]
// 表单数据
const formData = ref({
id: null,
reason: '',
occurredAt: '',
card: '',
amount: 0,
balance: 0,
type: 0,
classify: ''
})
// 类型文本
const typeText = computed(() => {
const option = typeOptions.find(o => o.value === formData.value.type)
return option?.text || ''
})
// 根据选中的类型过滤分类选项
const classifyOptions = computed(() => {
return categories.value.filter(c => c.type === formData.value.type)
})
// 监听modelValue变化
watch(() => props.modelValue, (val) => {
visible.value = val
if (val && props.transaction) {
initFormData()
loadCategories()
}
})
// 监听visible变化
watch(visible, (val) => {
emit('update:modelValue', val)
})
// 监听交易类型变化
watch(() => formData.value.type, () => {
// 清空已选的分类(如果当前分类不属于新类型)
const hasCurrentClassify = classifyOptions.value.some(
c => c.name === formData.value.classify
)
if (!hasCurrentClassify) {
formData.value.classify = ''
}
})
// 初始化表单数据
const initFormData = () => {
if (props.transaction) {
formData.value = {
id: props.transaction.id,
reason: props.transaction.reason || '',
occurredAt: props.transaction.occurredAt || '',
card: props.transaction.card || '',
amount: props.transaction.amount || 0,
balance: props.transaction.balance || 0,
type: props.transaction.type ?? 0,
classify: props.transaction.classify || ''
}
}
}
// 加载分类列表
const loadCategories = async () => {
try {
const res = await getCategoryList()
if (res.success) {
categories.value = res.data || []
}
} catch (error) {
console.error('获取分类列表失败:', error)
}
}
// 格式化日期时间
const formatDateTime = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 选择分类
const selectClassify = (classify) => {
formData.value.classify = classify
}
// 确认选择交易类型
const handleConfirmType = ({ selectedOptions }) => {
formData.value.type = selectedOptions[0].value
showTypePicker.value = false
}
// 新增分类
const addNewClassify = async () => {
if (!newClassify.value.trim()) {
showToast('请输入分类名称')
return
}
try {
const categoryName = newClassify.value.trim()
const response = await createCategory({
name: categoryName,
type: formData.value.type
})
if (response.success) {
showToast('分类创建成功')
await loadCategories()
formData.value.classify = categoryName
} else {
showToast(response.message || '创建分类失败')
}
} catch (error) {
console.error('创建分类出错:', error)
showToast('创建分类失败')
} finally {
newClassify.value = ''
showAddClassify.value = false
}
}
// 清空分类
const clearClassify = () => {
formData.value.classify = ''
}
// 保存
const handleSave = async () => {
try {
await formRef.value?.validate()
saving.value = true
const res = await updateTransaction({
id: formData.value.id,
amount: formData.value.amount,
balance: formData.value.balance,
type: formData.value.type,
classify: formData.value.classify || ''
})
if (res.success) {
showSuccessToast('保存成功')
visible.value = false
emit('saved', formData.value)
} else {
showToast(res.message || '保存失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('保存失败:', error)
showToast(error.message || '保存失败')
}
} finally {
saving.value = false
}
}
// 处理弹窗关闭
const handleClosed = () => {
formRef.value?.resetValidation()
}
</script>
<style scoped>
.transaction-detail-dialog {
height: 100%;
display: flex;
flex-direction: column;
}
.dialog-header {
padding: 20px 16px 12px;
border-bottom: 1px solid var(--van-border-color, #ebedf0);
}
.dialog-title {
margin: 0;
font-size: 16px;
font-weight: 500;
text-align: center;
}
.dialog-content {
flex: 1;
overflow-y: auto;
padding: 16px 0;
}
.dialog-footer {
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--van-border-color, #ebedf0);
}
.classify-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
max-height: 200px;
overflow-y: auto;
}
.classify-btn {
flex: 0 0 auto;
min-width: 70px;
border-radius: 16px;
}
@media (prefers-color-scheme: dark) {
.dialog-header,
.dialog-footer {
border-color: var(--van-border-color, #3a3a3a);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="transaction-list-container">
<div class="transaction-list-container transaction-list">
<van-list
:loading="loading"
:finished="finished"
@@ -10,13 +10,14 @@
<van-swipe-cell
v-for="transaction in transactions"
:key="transaction.id"
class="transaction-item"
>
<div class="transaction-row">
<van-checkbox
v-if="showCheckbox"
:model-value="isSelected(transaction.id)"
@update:model-value="toggleSelection(transaction)"
class="checkbox-col"
@update:model-value="toggleSelection(transaction)"
/>
<div
class="transaction-card"
@@ -37,9 +38,6 @@
</span>
</div>
<div v-if="transaction.card">
卡号: {{ transaction.card }}
</div>
<div v-if="transaction.importFrom">
来源: {{ transaction.importFrom }}
</div>
@@ -69,10 +67,10 @@
<div :class="['amount', getAmountClass(transaction.type)]">
{{ formatAmount(transaction.amount, transaction.type) }}
</div>
<div class="balance" v-if="transaction.balance && transaction.balance > 0">
<div v-if="transaction.balance && transaction.balance > 0" class="balance">
余额: {{ formatMoney(transaction.balance) }}
</div>
<div class="balance" v-if="transaction.refundAmount && transaction.refundAmount > 0">
<div v-if="transaction.refundAmount && transaction.refundAmount > 0" class="balance">
退款: {{ formatMoney(transaction.refundAmount) }}
</div>
</div>
@@ -80,7 +78,7 @@
</div>
</div>
</div>
<template #right v-if="showDelete">
<template v-if="showDelete" #right>
<van-button
square
type="danger"
@@ -164,6 +162,7 @@ const handleDeleteClick = async (transaction) => {
window.dispatchEvent(new CustomEvent('transaction-deleted', { detail: transaction.id }))
} catch (e) {
// ignore in non-browser environment
console.log('非浏览器环境,跳过派发 transaction-deleted 事件', e)
}
} else {
showToast(response.message || '删除失败')

View File

@@ -0,0 +1,25 @@
/**
* 预算周期类型
*/
export const BudgetPeriodType = {
Month: 1,
Year: 2
}
/**
* 预算类别
*/
export const BudgetCategory = {
Expense: 0,
Income: 1,
Savings: 2
}
/**
* 交易类型 (与后端 TransactionType 对应)
*/
export const TransactionType = {
Expense: 0,
Income: 1,
None: 2
}

View File

@@ -1,5 +1,6 @@
import './assets/main.css'
import './styles/common.css'
import './styles/rich-content.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'

View File

@@ -1,4 +1,13 @@
import { ref } from 'vue';
export const needRefresh = ref(false);
let swRegistration = null;
export async function updateServiceWorker() {
if (swRegistration && swRegistration.waiting) {
await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}
export function register() {
if ('serviceWorker' in navigator) {
@@ -8,8 +17,15 @@ export function register() {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
swRegistration = registration;
console.log('[SW] Service Worker 注册成功:', registration.scope);
// 如果已经有等待中的更新
if (registration.waiting) {
console.log('[SW] 发现未处理的新版本');
needRefresh.value = true;
}
// 检查更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
@@ -20,7 +36,7 @@ export function register() {
if (navigator.serviceWorker.controller) {
// 新的 Service Worker 已安装,提示用户刷新
console.log('[SW] 新版本可用,请刷新页面');
showUpdateNotification();
needRefresh.value = true;
} else {
// 首次安装
console.log('[SW] 内容已缓存,可离线使用');
@@ -59,13 +75,6 @@ export function unregister() {
}
}
// 显示更新提示
function showUpdateNotification() {
// 你可以使用 Vant 的 Dialog 或 Notify 组件
if (window.confirm('发现新版本,是否立即更新?')) {
window.location.reload();
}
}
// 请求通知权限
export function requestNotificationPermission() {

View File

@@ -73,7 +73,7 @@ const router = createRouter({
{
path: '/message',
name: 'message',
component: () => import('../views/MessageView.vue'),
redirect: { path: '/balance', query: { tab: 'message' } },
meta: { requiresAuth: true },
},
{
@@ -87,6 +87,25 @@ const router = createRouter({
name: 'log',
component: () => import('../views/LogView.vue'),
meta: { requiresAuth: true },
},
{
path: '/budget',
name: 'budget',
component: () => import('../views/BudgetView.vue'),
meta: { requiresAuth: true },
},
{
path: '/scheduled-tasks',
name: 'scheduled-tasks',
component: () => import('../views/ScheduledTasksView.vue'),
meta: { requiresAuth: true },
},
{
// 待确认的分类项
path: '/unconfirmed-classification',
name: 'unconfirmed-classification',
component: () => import('../views/UnconfirmedClassification.vue'),
meta: { requiresAuth: true },
}
],
})

View File

@@ -247,14 +247,27 @@
color: #51cf66;
}
/* 底部操作栏 */
.bottom-button {
position: fixed;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
left: 16px;
right: 16px;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 12px;
padding: 12px;
background-color: var(--van-background-2, #fff);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
z-index: 100;
}
@media (prefers-color-scheme: dark) {
.bottom-button {
background-color: var(--van-background-2, #2c2c2c);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
}
}
/* ===== 统一弹窗样式 ===== */
/* 弹窗容器 - 使用 flex 布局,确保标题固定,内容可滚动 */
.popup-container {

View File

@@ -0,0 +1,163 @@
/* 后端返回的 HTML 富文本内容样式 */
.rich-html-content {
font-size: 13px;
line-height: 1.5;
color: var(--van-text-color);
white-space: normal; /* 重置可能存在的 pre-wrap */
word-break: break-all;
}
.rich-html-content h1,
.rich-html-content h2,
.rich-html-content h3 {
margin: 10px 0 4px;
color: var(--van-text-color);
font-weight: 600;
line-height: 1.2;
}
.rich-html-content h1 {
font-size: 1.7em;
text-align: center;
border-bottom: 1px solid var(--van-border-color);
padding-bottom: 6px;
}
.rich-html-content h2 {
font-size: 1.5em;
margin-top: 14px;
}
.rich-html-content h3 {
font-size: 1.2em;
border-left: 3px solid #1989fa;
padding-left: 8px;
margin-top: 10px;
padding-top: 2px;
padding-bottom: 2px;
}
.rich-html-content p {
margin: 6px 0;
}
.rich-html-content ul,
.rich-html-content ol {
padding-left: 18px;
margin: 8px 0;
}
.rich-html-content li {
margin: 4px 0;
}
.rich-html-content strong {
font-weight: 600;
}
/* 表格样式优化 - 确保表格独立滚动且列对齐 */
.rich-html-content table {
display: block;
width: 100%;
border-collapse: collapse;
margin: 8px 0;
background: var(--van-background-2);
border-radius: 4px;
border: none;
overflow-x: auto; /* 仅表格内部横向滚动 */
-webkit-overflow-scrolling: touch;
overflow-y: auto;
max-height: 35vh;
}
.rich-html-content thead,
.rich-html-content tbody {
display: table;
width: 130%;
min-width: 400px; /* 确保窄屏下有足够宽度触发滚动 */
table-layout: fixed; /* 核心:强制列宽分配逻辑一致 */
}
.rich-html-content tr {
display: table-row;
}
.rich-html-content th,
.rich-html-content td {
display: table-cell;
padding: 8px;
text-align: left;
border: none;
border-bottom: 1px solid var(--van-border-color-light);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 针对第一列“名称”分配更多空间,其余平分 */
.rich-html-content th:first-child,
.rich-html-content td:first-child {
width: 30%;
}
.rich-html-content th:not(:first-child),
.rich-html-content td:not(:first-child) {
width: 20%;
}
.rich-html-content th {
background: var(--van-gray-1);
color: var(--van-text-color);
font-weight: 600;
}
/* 业务特定样式:收入、支出、高亮 */
.rich-html-content .income-value {
color: #07c160 !important;
font-weight: 600;
}
.rich-html-content .expense-value {
color: #ee0a24 !important;
font-weight: 600;
}
.rich-html-content .highlight {
background-color: #fffbe6;
color: #ed6a0c;
padding: 2px 6px;
border-radius: 4px;
font-weight: bold;
border: 1px solid #ffe58f;
margin: 0 2px;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.rich-html-content .highlight {
background-color: rgba(255, 243, 205, 0.2);
color: #ffc107;
border-color: rgba(255, 229, 143, 0.3);
}
.rich-html-content table {
background: #1a1a1a;
}
.rich-html-content th {
background-color: #242424;
color: #f5f5f5;
}
.rich-html-content td:first-child {
background-color: #1a1a1a;
}
.rich-html-content th:first-child {
background-color: #242424;
}
.rich-html-content th,
.rich-html-content td {
border-bottom: 1px solid #2c2c2c;
}
}

View File

@@ -1,16 +1,8 @@
<template>
<div class="page-container-flex">
<!-- 顶部导航栏 -->
<van-nav-bar title="交易记录" placeholder>
<van-nav-bar title="账单" placeholder>
<template #right>
<van-button
v-if="tabActive === 'balance'"
type="primary"
size="small"
@click="transactionsRecordRef.openAddDialog()"
>
手动录账
</van-button>
<van-button
v-if="tabActive === 'email'"
size="small"
@@ -20,27 +12,46 @@
>
立即同步
</van-button>
<van-icon
v-if="tabActive === 'message'"
name="passed"
size="20"
@click="messageViewRef?.handleMarkAllRead()"
/>
</template>
</van-nav-bar>
<van-tabs v-model:active="tabActive" animated>
<van-tab title="账单记录" name="balance" />
<van-tab title="邮件记录" name="email" />
<van-tab title="账单" name="balance" />
<van-tab title="邮件" name="email" />
<van-tab title="消息" name="message" />
</van-tabs>
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef"/>
<EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" />
<MessageView v-else-if="tabActive === 'message'" ref="messageViewRef" :is-component="true" />
</div>
</template>
<script setup>
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import TransactionsRecord from './TransactionsRecord.vue';
import EmailRecord from './EmailRecord.vue';
const tabActive = ref('balance');
import MessageView from './MessageView.vue';
const route = useRoute();
const tabActive = ref(route.query.tab || 'balance');
// 监听路由参数变化,用于从 tabbar 点击时切换 tab
watch(() => route.query.tab, (newTab) => {
if (newTab) {
tabActive.value = newTab;
}
});
const transactionsRecordRef = ref(null);
const emailRecordRef = ref(null);
const messageViewRef = ref(null);
</script>
<style scoped>

View File

@@ -1,4 +1,5 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="page-container-flex">
<!-- 顶部导航栏 -->
<van-nav-bar
@@ -6,7 +7,16 @@
left-arrow
placeholder
@click-left="onClickLeft"
>
<template #right>
<van-icon
name="setting-o"
size="20"
style="cursor: pointer; padding-right: 12px;"
@click="onClickPrompt"
/>
</template>
</van-nav-bar>
<div class="scroll-content analysis-content">
<!-- 输入区域 -->
@@ -30,8 +40,8 @@
type="primary"
plain
size="medium"
@click="selectQuestion(q)"
class="quick-tag"
@click="selectQuestion(q)"
>
{{ q }}
</van-tag>
@@ -43,26 +53,26 @@
round
:loading="analyzing"
loading-text="分析中..."
@click="startAnalysis"
:disabled="!userInput.trim()"
@click="startAnalysis"
>
开始分析
</van-button>
</div>
<!-- 结果区域 -->
<div class="result-section" v-if="showResult">
<div v-if="showResult" class="result-section">
<div class="result-header">
<h3>分析结果</h3>
<van-icon
v-if="!analyzing"
name="delete-o"
size="18"
@click="clearResult"
v-if="!analyzing"
/>
</div>
<div class="result-content" ref="resultContainer">
<div ref="resultContainer" class="result-content rich-html-content">
<div v-html="resultHtml"></div>
<van-loading v-if="analyzing" class="result-loading">
AI正在分析中...
@@ -71,13 +81,32 @@
</div>
</div>
</div>
<!-- 提示词设置弹窗 -->
<van-dialog
v-model:show="showPromptDialog"
title="编辑分析提示词"
:show-cancel-button="true"
@confirm="confirmPrompt"
>
<van-field
v-model="promptValue"
rows="4"
autosize
type="textarea"
maxlength="2000"
placeholder="输入自定义的分析提示词..."
show-word-limit
/>
</van-dialog>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { showToast, showLoadingToast, closeToast } from 'vant'
import { getConfig, setConfig } from '@/api/config'
const router = useRouter()
const userInput = ref('')
@@ -87,6 +116,10 @@ const resultHtml = ref('')
const resultContainer = ref(null)
const scrollAnchor = ref(null)
// 提示词弹窗相关
const showPromptDialog = ref(false)
const promptValue = ref('')
// 快捷问题
const quickQuestions = [
'最近三个月交通费用多少?',
@@ -100,6 +133,45 @@ const onClickLeft = () => {
router.back()
}
// 点击提示词按钮
const onClickPrompt = async () => {
try {
const response = await getConfig('BillAnalysisPrompt')
if (response.success) {
promptValue.value = response.data || ''
}
} catch (error) {
console.error('获取提示词失败:', error)
}
showPromptDialog.value = true
}
// 确认提示词
const confirmPrompt = async () => {
if (!promptValue.value.trim()) {
showToast('请输入提示词')
return
}
showLoadingToast({
message: '保存中...',
forbidClick: true
})
try {
const response = await setConfig('BillAnalysisPrompt', promptValue.value)
if (response.success) {
showToast('提示词已保存')
showPromptDialog.value = false
}
} catch (error) {
console.error('保存提示词失败:', error)
showToast('保存失败,请重试')
} finally {
closeToast()
}
}
// 选择快捷问题
const selectQuestion = (question) => {
userInput.value = question
@@ -131,7 +203,7 @@ const startAnalysis = async () => {
resultHtml.value = ''
try {
var baseUrl = import.meta.env.VITE_API_BASE_URL || ''
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
const response = await fetch(`${baseUrl}/TransactionRecord/AnalyzeBill`, {
method: 'POST',
headers: {
@@ -279,103 +351,12 @@ const startAnalysis = async () => {
padding: 20px;
}
/* 结果HTML样式 */
.result-content :deep(h1),
.result-content :deep(h2),
.result-content :deep(h3) {
color: var(--van-text-color);
margin: 16px 0 12px 0;
font-weight: 600;
}
.result-content :deep(h1) {
font-size: 20px;
}
.result-content :deep(h2) {
font-size: 18px;
}
.result-content :deep(h3) {
font-size: 16px;
}
.result-content :deep(p) {
margin: 8px 0;
color: var(--van-text-color);
}
.result-content :deep(ul),
.result-content :deep(ol) {
padding-left: 24px;
margin: 12px 0;
}
.result-content :deep(li) {
margin: 6px 0;
color: var(--van-text-color);
}
.result-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 14px;
}
.result-content :deep(th),
.result-content :deep(td) {
padding: 10px;
text-align: left;
border: 1px solid var(--van-border-color);
}
.result-content :deep(th) {
background: var(--van-background-2);
font-weight: 600;
color: var(--van-text-color);
}
.result-content :deep(td) {
color: var(--van-text-color);
}
.result-content :deep(strong) {
color: var(--van-text-color);
font-weight: 600;
}
.result-content :deep(.highlight) {
background: #fff3cd;
padding: 2px 6px;
border-radius: 4px;
color: #856404;
}
.result-content :deep(.expense-value) {
color: #ff6b6b;
font-weight: 600;
}
.result-content :deep(.income-value) {
color: #51cf66;
font-weight: 600;
}
.error-message {
color: #ff6b6b;
text-align: center;
padding: 20px;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.result-content :deep(.highlight) {
background: rgba(255, 243, 205, 0.2);
color: #ffc107;
}
}
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;

View File

@@ -0,0 +1,520 @@
<template>
<div class="page-container-flex">
<van-nav-bar title="预算管理" placeholder>
<template #right>
<van-icon
v-if="activeTab !== BudgetCategory.Savings && uncoveredCategories.length > 0"
name="warning-o"
size="20"
color="#ee0a24"
style="margin-right: 12px"
@click="showUncoveredDetails = true"
/>
<van-icon
v-if="activeTab !== BudgetCategory.Savings"
name="plus"
size="20"
@click="budgetEditRef.open({ category: activeTab })"
/>
<van-icon
v-else
name="setting-o"
size="20"
@click="savingsConfigRef.open()"
/>
</template>
</van-nav-bar>
<van-tabs v-model:active="activeTab" type="card" class="budget-tabs">
<van-tab title="支出" :name="BudgetCategory.Expense">
<BudgetSummary
v-if="activeTab !== BudgetCategory.Savings"
v-model:date="selectedDate"
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<van-pull-refresh v-model="isRefreshing" class="scroll-content" @refresh="onRefresh">
<div class="budget-list">
<template v-if="expenseBudgets?.length > 0">
<van-swipe-cell v-for="budget in expenseBudgets" :key="budget.id">
<BudgetCard
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{ 'warning': (budget.current / budget.limit) > 0.8 }"
:period-label="getPeriodLabel(budget.type)"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
@click="budgetEditRef.open({
data: budget,
isEditFlag: true,
category: budget.category
})"
>
<template #amount-info>
<div class="info-item">
<div class="label">已支出</div>
<div class="value expense">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
<div class="label">预算</div>
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
<div class="label">余额</div>
<div class="value" :class="budget.limit - budget.current >= 0 ? 'income' : 'expense'">
¥{{ formatMoney(budget.limit - budget.current) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
</template>
</van-swipe-cell>
</template>
<van-empty v-else description="暂无支出预算" />
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</van-pull-refresh>
</van-tab>
<van-tab title="收入" :name="BudgetCategory.Income">
<BudgetSummary
v-if="activeTab !== BudgetCategory.Savings"
v-model:date="selectedDate"
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<van-pull-refresh v-model="isRefreshing" class="scroll-content" @refresh="onRefresh">
<div class="budget-list">
<template v-if="incomeBudgets?.length > 0">
<van-swipe-cell v-for="budget in incomeBudgets" :key="budget.id">
<BudgetCard
:budget="budget"
:progress-color="getIncomeProgressColor(budget)"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
@click="budgetEditRef.open({
data: budget,
isEditFlag: true,
category: budget.category
})"
>
<template #amount-info>
<div class="info-item">
<div class="label">已收入</div>
<div class="value income">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
<div class="label">目标</div>
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
<div class="label">差额</div>
<div class="value" :class="budget.current >= budget.limit ? 'income' : 'expense'">
¥{{ formatMoney(Math.abs(budget.limit - budget.current)) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button square text="删除" type="danger" class="delete-button" @click="handleDelete(budget)" />
</template>
</van-swipe-cell>
</template>
<van-empty v-else description="暂无收入预算" />
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</van-pull-refresh>
</van-tab>
<van-tab title="存款" :name="BudgetCategory.Savings">
<van-pull-refresh v-model="isRefreshing" class="scroll-content" style="padding-top:4px" @refresh="onRefresh">
<div class="budget-list">
<template v-if="savingsBudgets?.length > 0">
<BudgetCard
v-for="budget in savingsBudgets"
:key="budget.id"
:budget="budget"
progress-color="#07c160"
:percent-class="{ 'income': (budget.current / budget.limit) >= 1 }"
:period-label="getPeriodLabel(budget.type)"
style="margin: 0 12px 12px;"
@switch-period="(dir) => handleSwitchPeriod(budget, dir)"
>
<template #amount-info>
<div class="info-item">
<div class="label">已存</div>
<div class="value income">¥{{ formatMoney(budget.current) }}</div>
</div>
<div class="info-item">
<div class="label">目标</div>
<div class="value">¥{{ formatMoney(budget.limit) }}</div>
</div>
<div class="info-item">
<div class="label">还差</div>
<div class="value expense">
¥{{ formatMoney(Math.max(0, budget.limit - budget.current)) }}
</div>
</div>
</template>
</BudgetCard>
</template>
<van-empty v-else description="暂无存款计划" />
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</van-pull-refresh>
</van-tab>
</van-tabs>
<BudgetEditPopup
ref="budgetEditRef"
@success="fetchBudgetList"
/>
<SavingsConfigPopup
ref="savingsConfigRef"
@success="fetchBudgetList"
/>
<PopupContainer
v-model="showUncoveredDetails"
title="未覆盖预算的分类"
:subtitle="`本月共 <b style='color:var(--van-primary-color)'>${uncoveredCategories.length}</b> 个分类未设置预算`"
height="60%"
>
<div class="uncovered-list">
<div v-for="item in uncoveredCategories" :key="item.category" class="uncovered-item">
<div class="item-left">
<div class="category-name">{{ item.category }}</div>
<div class="transaction-count">{{ item.transactionCount }} 笔记录</div>
</div>
<div class="item-right">
<div class="item-amount" :class="activeTab === BudgetCategory.Expense ? 'expense' : 'income'">
¥{{ formatMoney(item.totalAmount) }}
</div>
</div>
</div>
</div>
<template #footer>
<van-button block round type="primary" @click="showUncoveredDetails = false">
我知道了
</van-button>
</template>
</PopupContainer>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
import { getBudgetList, deleteBudget, getBudgetStatistics, getCategoryStats, getUncoveredCategories } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetSummary from '@/components/Budget/BudgetSummary.vue'
import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue'
import PopupContainer from '@/components/PopupContainer.vue'
const activeTab = ref(BudgetCategory.Expense)
const selectedDate = ref(new Date())
const budgetEditRef = ref(null)
const savingsConfigRef = ref(null)
const isRefreshing = ref(false)
const showUncoveredDetails = ref(false)
const uncoveredCategories = ref([])
const expenseBudgets = ref([])
const incomeBudgets = ref([])
const savingsBudgets = ref([])
const overallStats = ref({
month: { rate: '0.0', current: 0, limit: 0, count: 0 },
year: { rate: '0.0', current: 0, limit: 0, count: 0 }
})
const activeTabTitle = computed(() => {
if (activeTab.value === BudgetCategory.Expense) return '使用'
return '达成'
})
watch(activeTab, async () => {
await Promise.all([fetchCategoryStats(), fetchUncoveredCategories()])
})
watch(selectedDate, async () => {
await Promise.all([
fetchBudgetList(),
fetchCategoryStats(),
fetchUncoveredCategories()
])
})
const getValueClass = (rate) => {
const numRate = parseFloat(rate)
if (numRate === 0) return ''
if (activeTab.value === BudgetCategory.Expense) {
if (numRate >= 100) return 'expense'
if (numRate >= 80) return 'warning'
return 'income'
} else {
if (numRate >= 100) return 'income'
if (numRate >= 80) return 'warning'
return 'expense'
}
}
const fetchBudgetList = async () => {
try {
const res = await getBudgetList(selectedDate.value.toISOString())
if (res.success) {
const data = res.data || []
expenseBudgets.value = data.filter(b => b.category === BudgetCategory.Expense)
incomeBudgets.value = data.filter(b => b.category === BudgetCategory.Income)
savingsBudgets.value = data.filter(b => b.category === BudgetCategory.Savings)
}
} catch (err) {
console.error('加载预算列表失败', err)
}
}
const onRefresh = async () => {
try {
await Promise.all([fetchBudgetList(), fetchCategoryStats(), fetchUncoveredCategories()])
} catch (err) {
console.error('刷新失败', err)
} finally {
isRefreshing.value = false
}
}
const fetchCategoryStats = async () => {
try {
const res = await getCategoryStats(activeTab.value, selectedDate.value.toISOString())
if (res.success) {
// 转换后端返回的数据格式为前端需要的格式
const data = res.data
overallStats.value = {
month: {
rate: data.month?.rate?.toFixed(1) || '0.0',
current: data.month?.current || 0,
limit: data.month?.limit || 0,
count: data.month?.count || 0
},
year: {
rate: data.year?.rate?.toFixed(1) || '0.0',
current: data.year?.current || 0,
limit: data.year?.limit || 0,
count: data.year?.count || 0
}
}
}
} catch (err) {
console.error('加载分类统计失败', err)
}
}
const fetchUncoveredCategories = async () => {
if (activeTab.value === BudgetCategory.Savings) {
uncoveredCategories.value = []
return
}
try {
const res = await getUncoveredCategories(activeTab.value, selectedDate.value.toISOString())
if (res.success) {
uncoveredCategories.value = res.data || []
}
} catch (err) {
console.error('获取未覆盖分类失败', err)
}
}
onMounted(async () => {
try {
await Promise.all([
fetchBudgetList(),
fetchCategoryStats(),
fetchUncoveredCategories()
])
} catch (err) {
console.error('获取初始化数据失败', err)
}
})
const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })
}
const getPeriodLabel = (type) => {
const isCurrent = (date) => {
const now = new Date()
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth()
}
const isCurrentYear = (date) => {
const now = new Date()
return date.getFullYear() === now.getFullYear()
}
if (type === BudgetPeriodType.Month) {
return isCurrent(selectedDate.value) ? '本月' : `${selectedDate.value.getMonth() + 1}月`
}
if (type === BudgetPeriodType.Year) {
return isCurrentYear(selectedDate.value) ? '本年' : `${selectedDate.value.getFullYear()}年`
}
return '周期'
}
const getProgressColor = (budget) => {
const ratio = budget.current / budget.limit
if (ratio >= 1) return '#ee0a24'
if (ratio > 0.8) return '#ff976a'
return '#1989fa'
}
const getIncomeProgressColor = (budget) => {
const ratio = budget.current / budget.limit
if (ratio >= 1) return '#07c160'
return '#1989fa'
}
const refDateMap = {}
const handleSwitchPeriod = async (budget, direction) => {
let currentRefDate = refDateMap[budget.id] || new Date()
const date = new Date(currentRefDate)
if (budget.type === BudgetPeriodType.Month) {
date.setMonth(date.getMonth() + direction)
} else if (budget.type === BudgetPeriodType.Year) {
date.setFullYear(date.getFullYear() + direction)
}
try {
const res = await getBudgetStatistics(budget.id, date.toISOString())
if (res.success) {
refDateMap[budget.id] = date
Object.assign(budget, res.data)
}
} catch (err) {
showToast('加载历史统计失败')
console.error('加载预算历史统计失败', err)
}
}
const handleDelete = (budget) => {
showConfirmDialog({
title: '确认删除',
message: `确定要删除预算 "${budget.name}" `,
}).then(async () => {
try {
const res = await deleteBudget(budget.id)
if (res.success) {
showToast('已删除')
fetchBudgetList()
}
} catch (err) {
showToast('删除失败')
console.error('删除预算失败', err)
}
}).catch(() => {})
}
</script>
<style scoped>
.budget-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
margin-top: 12px;
min-height: 0;
}
:deep(.van-tabs__content) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
:deep(.van-tab__panel) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.budget-list {
padding-top: 8px;
padding-bottom: 20px;
}
.budget-list :deep(.van-swipe-cell) {
margin: 0 12px 12px;
}
.scroll-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.delete-button {
height: 100%;
}
:deep(.van-tabs__nav--card) {
margin: 0 12px;
}
.uncovered-list {
padding: 12px 16px;
}
.uncovered-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background-color: var(--van-background-2, #ffffff);
border-radius: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.item-left {
display: flex;
flex-direction: column;
}
.category-name {
font-size: 16px;
font-weight: 500;
color: var(--van-text-color, #323233);
margin-bottom: 4px;
}
.transaction-count {
font-size: 12px;
color: var(--van-text-color-2, #969799);
}
.item-right {
text-align: right;
}
.item-amount {
font-size: 18px;
font-weight: 600;
font-family: DIN Alternate, system-ui;
}
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
}
</style>

View File

@@ -16,7 +16,7 @@
v-model="listVisible"
:title="selectedDateText"
:subtitle="getBalance(dateTransactions)"
height="85%"
height="75%"
>
<template #header-actions>
<SmartClassifyButton
@@ -195,21 +195,21 @@ const viewDetail = async (transaction) => {
// 详情保存后的回调
const onDetailSave = async (saveData) => {
// 重新加载当前日期的交易列表
if (saveData && dateTransactions.value) {
var updatedIndex = dateTransactions.value.findIndex(tx => tx.id === saveData.id);
if (updatedIndex !== -1) {
// 更新已有记录
dateTransactions.value[updatedIndex].amount = saveData.amount;
dateTransactions.value[updatedIndex].balance = saveData.balance;
dateTransactions.value[updatedIndex].type = saveData.type;
dateTransactions.value[updatedIndex].upsetedType = '';
dateTransactions.value[updatedIndex].classify = saveData.classify;
dateTransactions.value[updatedIndex].upsetedClassify = '';
dateTransactions.value[updatedIndex].reason = saveData.reason;
}
var item = dateTransactions.value.find(tx => tx.id === saveData.id);
if(!item) return
// 如果分类发生了变化 移除智能分类的内容,防止被智能分类覆盖
if(item.classify !== saveData.classify) {
// 通知智能分类按钮组件移除指定项
smartClassifyButtonRef.value?.removeClassifiedTransaction(saveData.id)
item.upsetedClassify = ''
}
// 更新当前日期交易列表中的数据
Object.assign(item, saveData);
// 重新加载当前月份的统计数据
const now = selectedDate.value || new Date();
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
@@ -260,7 +260,7 @@ const now = new Date();
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1);
// 全局删除事件监听,确保日历页面数据一致
const onGlobalTransactionDeleted = (e) => {
const onGlobalTransactionDeleted = () => {
if (selectedDate.value) {
fetchDateTransactions(selectedDate.value)
}
@@ -275,7 +275,7 @@ onBeforeUnmount(() => {
})
// 当有交易被新增/修改/批量更新时刷新
const onGlobalTransactionsChanged = (e) => {
const onGlobalTransactionsChanged = () => {
if (selectedDate.value) {
fetchDateTransactions(selectedDate.value)
}
@@ -291,6 +291,10 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.van-calendar{
background: transparent !important;
}
.calendar-container {
/* 使用准确的视口高度减去 TabBar 高度50px和安全区域 */
height: calc(var(--vh, 100vh) - 50px - env(safe-area-inset-bottom, 0px));

View File

@@ -4,8 +4,8 @@
title="批量分类"
left-text="返回"
left-arrow
@click-left="handleBack"
placeholder
@click-left="handleBack"
/>
<div class="scroll-content">
@@ -65,7 +65,7 @@ const loadUnclassifiedCount = async () => {
}
// 处理数据加载完成
const handleDataLoaded = ({ groups, total, finished: isFinished }) => {
const handleDataLoaded = ({ groups, finished: isFinished }) => {
hasData.value = groups.length > 0
finished.value = isFinished
}

View File

@@ -4,8 +4,8 @@
:title="navTitle"
left-text="返回"
left-arrow
@click-left="handleBack"
placeholder
@click-left="handleBack"
/>
<div class="scroll-content">
@@ -29,8 +29,8 @@
<van-tag
type="primary"
closeable
@close="handleBackToRoot"
style="margin-left: 16px;"
@close="handleBackToRoot"
>
{{ currentTypeName }}
</van-tag>
@@ -41,7 +41,11 @@
<van-cell-group v-else inset>
<van-swipe-cell v-for="category in categories" :key="category.id">
<van-cell :title="category.name" />
<van-cell
:title="category.name"
is-link
@click="handleEdit(category)"
/>
<template #right>
<van-button
square
@@ -54,12 +58,14 @@
</van-cell-group>
</div>
<!-- 底部安全距离 -->
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div>
<div class="bottom-button">
<!-- 新增分类按钮 -->
<van-button
type="primary"
size="large"
round
icon="plus"
@click="handleAddCategory"
>
@@ -85,6 +91,24 @@
</van-form>
</van-dialog>
<!-- 编辑分类对话框 -->
<van-dialog
v-model:show="showEditDialog"
title="编辑分类"
show-cancel-button
@confirm="handleConfirmEdit"
>
<van-form ref="editFormRef">
<van-field
v-model="editForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</van-dialog>
<!-- 删除确认对话框 -->
<van-dialog
v-model:show="showDeleteConfirm"
@@ -108,7 +132,8 @@ import {
import {
getCategoryList,
createCategory,
deleteCategory
deleteCategory,
updateCategory
} from '@/api/transactionCategory'
const router = useRouter()
@@ -142,6 +167,14 @@ const addForm = ref({
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
// 编辑对话框
const showEditDialog = ref(false)
const editFormRef = ref(null)
const editForm = ref({
id: 0,
name: ''
})
// 计算导航栏标题
const navTitle = computed(() => {
if (currentLevel.value === 0) {
@@ -251,6 +284,50 @@ const handleConfirmAdd = async () => {
}
}
/**
* 编辑分类
*/
const handleEdit = (category) => {
editForm.value = {
id: category.id,
name: category.name
}
showEditDialog.value = true
}
/**
* 确认编辑
*/
const handleConfirmEdit = async () => {
try {
await editFormRef.value?.validate()
showLoadingToast({
message: '保存中...',
forbidClick: true,
duration: 0
})
const { success, message } = await updateCategory({
id: editForm.value.id,
name: editForm.value.name
})
if (success) {
showSuccessToast('保存成功')
showEditDialog.value = false
await loadCategories()
} else {
showToast(message || '保存失败')
}
} catch (error) {
console.error('保存失败:', error)
showToast('保存失败: ' + (error.message || '未知错误'))
} finally {
closeToast()
}
}
/**
* 删除分类
*/

View File

@@ -62,7 +62,7 @@
<PopupContainer
v-model="showRecordsList"
title="交易记录列表"
height="80%"
height="75%"
>
<div style="background: var(--van-background, #f7f8fa);">
<!-- 批量操作按钮 -->
@@ -102,9 +102,9 @@
:finished="true"
:show-checkbox="true"
:selected-ids="selectedIds"
:show-delete="false"
@update:selected-ids="updateSelectedIds"
@click="handleRecordClick"
:show-delete="false"
/>
</div>
</div>

View File

@@ -27,13 +27,14 @@
</div>
<!-- 底部操作按钮 -->
<div class="action-bar">
<div class="bottom-button">
<van-button
type="primary"
:loading="classifying"
:disabled="selectedCount === 0"
@click="startClassify"
round
class="action-btn"
@click="startClassify"
>
{{ classifying ? '分类中...' : `开始分类 (${selectedCount}组)` }}
</van-button>
@@ -41,8 +42,9 @@
<van-button
type="success"
:disabled="!hasChanges || classifying"
@click="saveClassifications"
round
class="action-btn"
@click="saveClassifications"
>
保存分类
</van-button>
@@ -89,7 +91,7 @@ const loadUnclassifiedCount = async () => {
}
// 处理数据加载完成
const handleDataLoaded = ({ groups, total }) => {
const handleDataLoaded = ({ total }) => {
totalGroups.value = total
// 默认全选所有分组
if (groupListRef.value) {
@@ -351,27 +353,6 @@ onMounted(async () => {
font-weight: 500;
}
/* 底部操作栏 */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 12px;
padding: 12px;
background-color: var(--van-background-2, #fff);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
z-index: 100;
}
@media (prefers-color-scheme: dark) {
.action-bar {
background-color: var(--van-background-2, #2c2c2c);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
}
}
.action-btn {
flex: 1;
height: 44px;

View File

@@ -1,4 +1,5 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="page-container-flex">
<!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
@@ -27,7 +28,7 @@
<template #value>
<div class="email-info">
<div class="email-date">{{ formatDate(email.receivedDate) }}</div>
<div class="bill-count" v-if="email.transactionCount > 0">
<div v-if="email.transactionCount > 0" class="bill-count">
<span style="font-size: 12px;">已解析{{ email.transactionCount }}条账单</span>
</div>
@@ -57,36 +58,41 @@
</van-pull-refresh>
<!-- 详情弹出层 -->
<van-popup
v-model:show="detailVisible"
position="bottom"
:style="{ height: '80%' }"
round
closeable
<PopupContainer
v-model="detailVisible"
:title="currentEmail ? (currentEmail.Subject || currentEmail.subject || '(无主题)') : ''"
height="75%"
>
<div class="popup-container" v-if="currentEmail">
<div class="popup-header-fixed">
<h3>{{ currentEmail.Subject || currentEmail.subject || '(无主题)' }}</h3>
</div>
<div class="popup-scroll-content">
<template #header-actions>
<van-button
size="small"
type="primary"
:loading="refreshingAnalysis"
@click="handleRefreshAnalysis"
>
重新分析
</van-button>
</template>
<div v-if="currentEmail">
<van-cell-group inset style="margin-top: 12px;">
<van-cell title="发件人" :value="currentEmail.From || currentEmail.from || '未知'" />
<van-cell title="接收时间" :value="formatDate(currentEmail.ReceivedDate || currentEmail.receivedDate)" />
<van-cell title="记录时间" :value="formatDate(currentEmail.CreateTime || currentEmail.createTime)" />
<van-cell
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0"
title="已解析账单数"
:value="`${currentEmail.TransactionCount || currentEmail.transactionCount || 0}条`"
is-link
@click="viewTransactions"
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0"
/>
</van-cell-group>
<div class="email-content">
<h4 style="margin-left: 10px;">邮件内容</h4>
<div
v-if="currentEmail.htmlBody"
v-html="currentEmail.htmlBody"
class="content-body html-content"
v-html="currentEmail.htmlBody"
></div>
<div
v-else-if="currentEmail.body"
@@ -101,27 +107,14 @@
</div>
</div>
</div>
<div style="margin: 16px;">
<van-button
round
block
type="primary"
:loading="refreshingAnalysis"
@click="handleRefreshAnalysis"
>
重新分析
</van-button>
</div>
</div>
</div>
</van-popup>
</PopupContainer>
<!-- 账单列表弹出层 -->
<PopupContainer
v-model="transactionListVisible"
title="关联账单列表"
height="70%"
height="75%"
>
<TransactionList
:transactions="transactionList"
@@ -343,6 +336,7 @@ const viewTransactions = async () => {
// 监听全局删除事件,保持弹窗内交易列表一致
const onGlobalTransactionDeleted = (e) => {
console.log('收到全局交易删除事件:', e)
// 如果交易列表弹窗打开,尝试重新加载邮箱的交易列表
if (transactionListVisible.value && currentEmail.value) {
const emailId = currentEmail.value.id || currentEmail.value.Id
@@ -362,6 +356,7 @@ onBeforeUnmount(() => {
// 监听新增/修改/批量更新事件,刷新弹窗内交易或邮件列表
const onGlobalTransactionsChanged = (e) => {
console.log('收到全局交易变更事件:', e)
if (transactionListVisible.value && currentEmail.value) {
const emailId = currentEmail.value.id || currentEmail.value.Id
getEmailTransactions(emailId).then(response => {
@@ -413,7 +408,12 @@ const handleTransactionDelete = (transactionId) => {
}
})
}
try { window.dispatchEvent(new CustomEvent('transaction-deleted', { detail: transactionId })) } catch(e) {}
try {
window.dispatchEvent(
new CustomEvent('transaction-deleted', { detail: transactionId }))
} catch(e) {
console.error(e)
}
}
// 账单保存后刷新列表
@@ -426,7 +426,18 @@ const handleTransactionSave = async () => {
transactionList.value = response.data || []
}
}
try { window.dispatchEvent(new CustomEvent('transactions-changed', { detail: { emailId: currentEmail.value?.id } })) } catch(e) {}
try {
window.dispatchEvent(
new CustomEvent(
'transactions-changed',
{
detail: {
emailId: currentEmail.value?.id
}
}))
} catch(e) {
console.error(e)
}
}
// 格式化日期
@@ -443,6 +454,8 @@ const formatDate = (dateString) => {
})
}
onMounted(() => {
loadData(true)
})

View File

@@ -4,8 +4,8 @@
title="查看日志"
left-text="返回"
left-arrow
@click-left="handleBack"
placeholder
@click-left="handleBack"
/>
<div class="scroll-content">
@@ -38,8 +38,8 @@
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
class="log-list"
@load="onLoad"
>
<div
v-for="(log, index) in logList"

View File

@@ -21,8 +21,8 @@
block
round
:loading="loading"
@click="handleLogin"
class="login-button"
@click="handleLogin"
>
登录
</van-button>

View File

@@ -1,11 +1,6 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="page-container-flex">
<van-nav-bar title="消息中心">
<template #right>
<van-icon name="passed" size="18" @click="handleMarkAllRead" />
</template>
</van-nav-bar>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
@@ -46,24 +41,36 @@
v-model="detailVisible"
:title="currentMessage.title"
:subtitle="currentMessage.createTime"
height="50%"
:closeable="true"
height="75%"
>
<div class="detail-content">
<div
v-if="currentMessage.messageType === 2"
class="detail-content rich-html-content"
v-html="currentMessage.content"
>
</div>
<div v-else class="detail-content">
{{ currentMessage.content }}
</div>
<template v-if="currentMessage.url && currentMessage.messageType === 1" #footer>
<van-button type="primary" block round @click="handleUrlJump(currentMessage.url)">
查看详情
</van-button>
</template>
</PopupContainer>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { showToast, showDialog } from 'vant';
import { getMessageList, markAsRead, deleteMessage, markAllAsRead } from '@/api/message';
import { useMessageStore } from '@/stores/message';
import PopupContainer from '@/components/PopupContainer.vue';
const messageStore = useMessageStore();
const router = useRouter();
const list = ref([]);
const loading = ref(false);
const finished = ref(false);
@@ -126,9 +133,6 @@ const onRefresh = () => {
};
const viewDetail = async (item) => {
currentMessage.value = item;
detailVisible.value = true;
if (!item.isRead) {
try {
await markAsRead(item.id);
@@ -138,6 +142,22 @@ const viewDetail = async (item) => {
console.error('标记已读失败', error);
}
}
currentMessage.value = item;
detailVisible.value = true;
};
const handleUrlJump = (targetUrl) => {
if (!targetUrl) return;
if (targetUrl.startsWith('http')) {
window.open(targetUrl, '_blank');
} else if (targetUrl.startsWith('/')) {
router.push(targetUrl);
detailVisible.value = false;
} else {
showToast('无效的URL');
}
};
const handleDelete = (item) => {
@@ -196,6 +216,10 @@ const handleMarkAllRead = () => {
onMounted(() => {
// onLoad 会由 van-list 自动触发
});
defineExpose({
handleMarkAllRead
});
</script>
<style scoped>
@@ -261,10 +285,13 @@ onMounted(() => {
}
.detail-content {
padding: 20px;
font-size: 16px;
padding: 16px;
font-size: 14px;
line-height: 1.6;
color: var(--van-text-color);
}
.detail-content:not(.rich-html-content) {
white-space: pre-wrap;
}

View File

@@ -4,8 +4,8 @@
:title="navTitle"
left-text="返回"
left-arrow
@click-left="handleBack"
placeholder
@click-left="handleBack"
/>
<!-- 下拉刷新区域 -->
@@ -20,10 +20,10 @@
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
class="periodic-list"
@load="onLoad"
>
<van-cell-group inset v-for="item in periodicList" :key="item.id" class="periodic-item">
<van-cell-group v-for="item in periodicList" :key="item.id" inset class="periodic-item">
<van-swipe-cell>
<div @click="editPeriodic(item)">
<van-cell :title="item.reason || '无摘要'" :label="getPeriodicTypeText(item)">
@@ -36,6 +36,7 @@
</template>
</van-cell>
<van-cell title="分类" :value="item.classify || '未分类'" />
<van-cell title="下次执行时间" :value="formatDateTime(item.nextExecuteTime) || '未设置'" />
<van-cell title="状态">
<template #value>
<van-switch
@@ -68,7 +69,7 @@
</van-list>
<!-- 底部安全距离 -->
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</van-pull-refresh>
<!-- 底部新增按钮 -->
@@ -88,9 +89,9 @@
<PopupContainer
v-model="dialogVisible"
:title="isEdit ? '编辑周期账单' : '新增周期账单'"
height="85%"
height="75%"
>
<van-form @submit="onSubmit">
<van-form>
<van-cell-group inset title="周期设置">
<van-field
v-model="form.periodicTypeText"
@@ -99,8 +100,8 @@
name="periodicType"
label="周期"
placeholder="请选择周期类型"
@click="showPeriodicTypePicker = true"
:rules="[{ required: true, message: '请选择周期类型' }]"
@click="showPeriodicTypePicker = true"
/>
<!-- 每周配置 -->
@@ -112,8 +113,8 @@
name="weekdays"
label="星期"
placeholder="请选择星期几"
@click="showWeekdaysPicker = true"
:rules="[{ required: true, message: '请选择星期几' }]"
@click="showWeekdaysPicker = true"
/>
<!-- 每月配置 -->
@@ -125,8 +126,8 @@
name="monthDays"
label="日期"
placeholder="请选择每月的日期"
@click="showMonthDaysPicker = true"
:rules="[{ required: true, message: '请选择日期' }]"
@click="showMonthDaysPicker = true"
/>
<!-- 每季度配置 -->
@@ -173,15 +174,18 @@
:rules="[{ required: true, message: '请输入金额' }]"
/>
<van-field
v-model="form.typeText"
is-link
readonly
v-model="form.type"
name="type"
label="类型"
placeholder="请选择交易类型"
@click="showTypePicker = true"
:rules="[{ required: true, message: '请选择交易类型' }]"
/>
>
<template #input>
<van-radio-group v-model="form.type" direction="horizontal">
<van-radio :name="0">支出</van-radio>
<van-radio :name="1">收入</van-radio>
<van-radio :name="2">不计</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field name="classify" label="分类">
<template #input>
<span v-if="!form.classify" style="color: #c8c9cc;">请选择交易分类</span>
@@ -189,57 +193,22 @@
</template>
</van-field>
<!-- 分类按钮网格 -->
<div class="classify-buttons">
<van-button
v-for="item in classifyColumns"
:key="item.id"
:type="form.classify === item.text ? 'primary' : 'default'"
size="small"
class="classify-btn"
@click="selectClassify(item.text)"
>
{{ item.text }}
</van-button>
<van-button
type="success"
size="small"
class="classify-btn"
@click="showAddClassify = true"
>
+ 新增
</van-button>
<van-button
v-if="form.classify"
type="warning"
size="small"
class="classify-btn"
@click="clearClassify"
>
清空
</van-button>
</div>
<!-- 分类选择组件 -->
<ClassifySelector
v-model="form.classify"
:type="form.type"
/>
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit" :loading="submitting">
</van-form>
<template #footer>
<van-button round block type="primary" :loading="submitting" @click="submit">
{{ isEdit ? '更新' : '确认添加' }}
</van-button>
</div>
</van-form>
</template>
</PopupContainer>
<!-- 交易类型选择器 -->
<van-popup v-model:show="showTypePicker" position="bottom" round>
<van-picker
:columns="typeColumns"
@confirm="onTypeConfirm"
@cancel="showTypePicker = false"
/>
</van-popup>
<!-- 周期类型选择器 -->
<van-popup v-model:show="showPeriodicTypePicker" position="bottom" round>
<van-popup v-model:show="showPeriodicTypePicker" position="bottom" round teleport="body">
<van-picker
:columns="periodicTypeColumns"
@confirm="onPeriodicTypeConfirm"
@@ -248,7 +217,7 @@
</van-popup>
<!-- 星期选择器 -->
<van-popup v-model:show="showWeekdaysPicker" position="bottom" round>
<van-popup v-model:show="showWeekdaysPicker" position="bottom" round teleport="body">
<van-picker
:columns="weekdaysColumns"
@confirm="onWeekdaysConfirm"
@@ -257,39 +226,28 @@
</van-popup>
<!-- 日期选择器 -->
<van-popup v-model:show="showMonthDaysPicker" position="bottom" round>
<van-popup v-model:show="showMonthDaysPicker" position="bottom" round teleport="body">
<van-picker
:columns="monthDaysColumns"
@confirm="onMonthDaysConfirm"
@cancel="showMonthDaysPicker = false"
/>
</van-popup>
<!-- 新增分类对话框 -->
<van-dialog
v-model:show="showAddClassify"
title="新增交易分类"
show-cancel-button
@confirm="addNewClassify"
>
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
</van-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showConfirmDialog } from 'vant'
import {
getPeriodicList,
createPeriodic,
updatePeriodic,
deletePeriodic as deletePeriodicApi,
togglePeriodicEnabled
} from '@/api/transactionPeriodic'
import { getCategoryList, createCategory } from '@/api/transactionCategory'
import PopupContainer from '@/components/PopupContainer.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import dayjs from 'dayjs'
const router = useRouter()
const navTitle = ref('周期账单')
@@ -306,22 +264,9 @@ const total = ref(0)
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const showTypePicker = ref(false)
const showPeriodicTypePicker = ref(false)
const showWeekdaysPicker = ref(false)
const showMonthDaysPicker = ref(false)
const showAddClassify = ref(false)
const newClassify = ref('')
// 分类列表
const classifyColumns = ref([])
// 交易类型
const typeColumns = [
{ text: '支出', value: 0 },
{ text: '收入', value: 1 },
{ text: '不计入收支', value: 2 }
]
// 周期类型
const periodicTypeColumns = [
@@ -355,7 +300,6 @@ const form = reactive({
reason: '',
amount: '',
type: 0,
typeText: '',
classify: '',
periodicType: 0,
periodicTypeText: '',
@@ -481,24 +425,6 @@ const openAddDialog = () => {
isEdit.value = false
resetForm()
dialogVisible.value = true
// 加载分类列表
loadClassifyList(form.type)
}
// 加载分类列表
const loadClassifyList = async (type = null) => {
try {
const response = await getCategoryList(type)
if (response.success) {
classifyColumns.value = (response.data || []).map(item => ({
text: item.name,
value: item.name,
id: item.id
}))
}
} catch (error) {
console.error('加载分类列表出错:', error)
}
}
// 编辑
@@ -508,14 +434,10 @@ const editPeriodic = (item) => {
form.reason = item.reason
form.amount = item.amount.toString()
form.type = item.type
form.typeText = typeColumns.find(t => t.value === item.type)?.text || ''
form.classify = item.classify
form.periodicType = item.periodicType
form.periodicTypeText = periodicTypeColumns.find(t => t.value === item.periodicType)?.text || ''
// 加载对应类型的分类列表
loadClassifyList(item.type)
// 解析周期配置
if (item.periodicConfig) {
switch (item.periodicType) {
@@ -587,13 +509,18 @@ const toggleEnabled = async (id, enabled) => {
}
}
const formatDateTime = (date) => {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
// 重置表单
const resetForm = () => {
form.id = null
form.reason = ''
form.amount = ''
form.type = 0
form.typeText = ''
form.classify = ''
form.periodicType = 0
form.periodicTypeText = ''
@@ -606,17 +533,6 @@ const resetForm = () => {
form.yearDay = ''
}
// 选择器确认事件
const onTypeConfirm = ({ selectedValues, selectedOptions }) => {
form.type = selectedValues[0]
form.typeText = selectedOptions[0].text
showTypePicker.value = false
// 清空已选的分类
form.classify = ''
// 重新加载对应类型的分类列表
loadClassifyList(form.type)
}
const onPeriodicTypeConfirm = ({ selectedValues, selectedOptions }) => {
form.periodicType = selectedValues[0]
form.periodicTypeText = selectedOptions[0].text
@@ -642,124 +558,6 @@ const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
showMonthDaysPicker.value = false
}
// 选择分类
const selectClassify = (classify) => {
form.classify = classify
}
// 清空分类
const clearClassify = () => {
form.classify = ''
showToast('已清空分类')
}
// 新增分类
const addNewClassify = async () => {
if (!newClassify.value.trim()) {
showToast('请输入分类名称')
return
}
try {
const categoryName = newClassify.value.trim()
// 调用API创建分类
const response = await createCategory({
name: categoryName,
type: form.type
})
if (response.success) {
showToast('分类创建成功')
// 重新加载分类列表
await loadClassifyList(form.type)
form.classify = categoryName
} else {
showToast(response.message || '创建分类失败')
}
} catch (error) {
console.error('创建分类出错:', error)
showToast('创建分类失败')
} finally {
newClassify.value = ''
showAddClassify.value = false
}
}
// 提交表单
const onSubmit = async () => {
try {
submitting.value = true
// 构建周期配置
let periodicConfig = ''
switch (form.periodicType) {
case 1: // 每周
if (!form.weekdays.length) {
showToast('请选择星期几')
return
}
periodicConfig = form.weekdays.join(',')
break
case 2: // 每月
if (!form.monthDays.length) {
showToast('请选择日期')
return
}
periodicConfig = form.monthDays.join(',')
break
case 3: // 每季度
if (!form.quarterDay) {
showToast('请输入季度开始后第几天')
return
}
periodicConfig = form.quarterDay
break
case 4: // 每年
if (!form.yearDay) {
showToast('请输入年开始后第几天')
return
}
periodicConfig = form.yearDay
break
}
const data = {
periodicType: form.periodicType,
periodicConfig: periodicConfig,
amount: parseFloat(form.amount),
type: form.type,
classify: form.classify || '',
reason: form.reason || ''
}
let response
if (isEdit.value) {
data.id = form.id
data.isEnabled = true
response = await updatePeriodic(data)
} else {
response = await createPeriodic(data)
}
if (response.success) {
showToast(isEdit.value ? '更新成功' : '添加成功')
dialogVisible.value = false
loadData(true)
} else {
showToast(response.message || (isEdit.value ? '更新失败' : '添加失败'))
}
} catch (error) {
console.error('提交出错:', error)
showToast((isEdit.value ? '更新' : '添加') + '失败')
} finally {
submitting.value = false
}
}
onMounted(() => {
// van-list 会自动触发 onLoad
})
</script>
<style scoped>
@@ -798,17 +596,4 @@ onMounted(() => {
height: 100%;
}
.classify-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
}
.classify-btn {
flex: 0 0 auto;
min-width: 70px;
border-radius: 16px;
}
</style>

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