36 Commits

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

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

View File

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

9080
.pans/v2.pen Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

218
AGENTS.md Normal file
View File

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

3
Common/GlobalUsings.cs Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# 多阶段构建 Dockerfile # 多阶段构建 Dockerfile
# 第一阶段:构建前端 # 第一阶段:构建前端
FROM node:20-alpine AS frontend-build FROM node:20-slim AS frontend-build
WORKDIR /app/frontend WORKDIR /app/frontend
@@ -31,6 +31,7 @@ COPY Entity/*.csproj ./Entity/
COPY Repository/*.csproj ./Repository/ COPY Repository/*.csproj ./Repository/
COPY Service/*.csproj ./Service/ COPY Service/*.csproj ./Service/
COPY WebApi/*.csproj ./WebApi/ COPY WebApi/*.csproj ./WebApi/
COPY WebApi.Test/*.csproj ./WebApi.Test/
# 还原依赖 # 还原依赖
RUN dotnet restore RUN dotnet restore

View File

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

View File

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

44
Entity/AGENTS.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

156
REFACTORING_SUMMARY.md Normal file
View File

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

46
Repository/AGENTS.md Normal file
View File

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

View File

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

View File

@@ -28,10 +28,6 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
{ {
query = query.Where(t => t.Type == TransactionType.Income); query = query.Where(t => t.Type == TransactionType.Income);
} }
else if (budget.Category == BudgetCategory.Savings)
{
query = query.Where(t => t.Type == TransactionType.None);
}
return await query.SumAsync(t => t.Amount); return await query.SumAsync(t => t.Amount);
} }
@@ -41,14 +37,13 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
var records = await FreeSql.Select<BudgetRecord>() var records = await FreeSql.Select<BudgetRecord>()
.Where(b => b.SelectedCategories.Contains(oldName) && .Where(b => b.SelectedCategories.Contains(oldName) &&
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) || ((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
(type == TransactionType.Income && b.Category == BudgetCategory.Income) || (type == TransactionType.Income && b.Category == BudgetCategory.Income)))
(type == TransactionType.None && b.Category == BudgetCategory.Savings)))
.ToListAsync(); .ToListAsync();
foreach (var record in records) foreach (var record in records)
{ {
var categories = record.SelectedCategories.Split(',').ToList(); var categories = record.SelectedCategories.Split(',').ToList();
for (int i = 0; i < categories.Count; i++) for (var i = 0; i < categories.Count; i++)
{ {
if (categories[i] == oldName) if (categories[i] == oldName)
{ {

View File

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

View File

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

View File

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

55
Service/AGENTS.md Normal file
View File

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

View File

@@ -1,21 +1,21 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
namespace Service; namespace Service.AI;
public interface IOpenAiService public interface IOpenAiService
{ {
Task<string?> ChatAsync(string systemPrompt, string userPrompt); Task<string?> ChatAsync(string systemPrompt, string userPrompt, int timeoutSeconds = 15);
Task<string?> ChatAsync(string prompt); Task<string?> ChatAsync(string prompt, int timeoutSeconds = 15);
IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt); IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt);
IAsyncEnumerable<string> ChatStreamAsync(string prompt); IAsyncEnumerable<string> ChatStreamAsync(string prompt);
} }
public class OpenAiService( public class OpenAiService(
IOptions<AISettings> aiSettings, IOptions<AiSettings> aiSettings,
ILogger<OpenAiService> logger ILogger<OpenAiService> logger
) : IOpenAiService ) : IOpenAiService
{ {
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt) public async Task<string?> ChatAsync(string systemPrompt, string userPrompt, int timeoutSeconds = 15)
{ {
var cfg = aiSettings.Value; var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) || if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
@@ -27,7 +27,7 @@ public class OpenAiService(
} }
using var http = new HttpClient(); using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(15); http.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new var payload = new
@@ -72,7 +72,7 @@ public class OpenAiService(
} }
} }
public async Task<string?> ChatAsync(string prompt) public async Task<string?> ChatAsync(string prompt, int timeoutSeconds = 15)
{ {
var cfg = aiSettings.Value; var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) || if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
@@ -84,7 +84,7 @@ public class OpenAiService(
} }
using var http = new HttpClient(); using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(60 * 5); http.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new var payload = new
@@ -158,10 +158,8 @@ public class OpenAiService(
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, url) using var request = new HttpRequestMessage(HttpMethod.Post, url);
{ request.Content = content;
Content = content
};
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
@@ -232,10 +230,8 @@ public class OpenAiService(
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
// 使用 SendAsync 来支持 HttpCompletionOption // 使用 SendAsync 来支持 HttpCompletionOption
using var request = new HttpRequestMessage(HttpMethod.Post, url) using var request = new HttpRequestMessage(HttpMethod.Post, url);
{ request.Content = content;
Content = content
};
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)

View File

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

View File

@@ -1,8 +1,7 @@
namespace Service; using JiebaNet.Analyser;
using JiebaNet.Segmenter; using JiebaNet.Segmenter;
using JiebaNet.Analyser;
using Microsoft.Extensions.Logging; namespace Service.AI;
/// <summary> /// <summary>
/// 文本分词服务接口 /// 文本分词服务接口
@@ -78,7 +77,7 @@ public class TextSegmentService : ITextSegmentService
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
return new List<string>(); return [];
} }
try try
@@ -119,7 +118,7 @@ public class TextSegmentService : ITextSegmentService
{ {
_logger.LogError(ex, "提取关键词失败,文本: {Text}", text); _logger.LogError(ex, "提取关键词失败,文本: {Text}", text);
// 降级处理:返回原文 // 降级处理:返回原文
return new List<string> { text.Length > 10 ? text.Substring(0, 10) : text }; return [text.Length > 10 ? text.Substring(0, 10) : text];
} }
} }
@@ -127,7 +126,7 @@ public class TextSegmentService : ITextSegmentService
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
return new List<string>(); return [];
} }
try try
@@ -146,7 +145,7 @@ public class TextSegmentService : ITextSegmentService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "分词失败,文本: {Text}", text); _logger.LogError(ex, "分词失败,文本: {Text}", text);
return new List<string> { text }; return [text];
} }
} }
} }

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,150 @@
using Quartz;
using Service.AI;
namespace Service.Jobs;
/// <summary>
/// 分类图标生成定时任务
/// 每10分钟扫描一次为没有图标的分类生成 5 个 SVG 图标
/// </summary>
public class CategoryIconGenerationJob(
ITransactionCategoryRepository categoryRepository,
IOpenAiService openAiService,
ILogger<CategoryIconGenerationJob> logger) : IJob
{
private static readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task Execute(IJobExecutionContext context)
{
// 尝试获取锁,如果失败则跳过本次执行
if (!await _semaphore.WaitAsync(0))
{
logger.LogInformation("上一个分类图标生成任务尚未完成,跳过本次执行");
return;
}
try
{
logger.LogInformation("开始执行分类图标生成任务");
// 查询所有分类,然后过滤出没有图标的
var allCategories = await categoryRepository.GetAllAsync();
var categoriesWithoutIcon = allCategories
.Where(c => string.IsNullOrEmpty(c.Icon))
.ToList();
if (categoriesWithoutIcon.Count == 0)
{
logger.LogInformation("所有分类都已有图标,跳过本次任务");
return;
}
logger.LogInformation("发现 {Count} 个分类没有图标,开始生成", categoriesWithoutIcon.Count);
// 为每个分类生成图标
foreach (var category in categoriesWithoutIcon)
{
try
{
await GenerateIconsForCategoryAsync(category);
}
catch (Exception ex)
{
logger.LogError(ex, "为分类 {CategoryName}(ID:{CategoryId}) 生成图标失败",
category.Name, category.Id);
}
}
logger.LogInformation("分类图标生成任务执行完成");
}
catch (Exception ex)
{
logger.LogError(ex, "分类图标生成任务执行出错");
throw;
}
finally
{
_semaphore.Release();
}
}
/// <summary>
/// 为单个分类生成 5 个 SVG 图标
/// </summary>
private async Task GenerateIconsForCategoryAsync(TransactionCategory category)
{
logger.LogInformation("正在为分类 {CategoryName}(ID:{CategoryId}) 生成图标",
category.Name, category.Id);
var typeText = category.Type == TransactionType.Expense ? "支出" : "收入";
var systemPrompt = """
SVG
5 SVG
1. 24x24viewBox="0 0 24 24"
2. 使
- 使 <linearGradient> <radialGradient>
- 使
-
3. 5
- 1使
- 2线
- 33D使
- 4
- 5线
4.
-
-
-
5.
6. JSON 5 SVG
SVG gradient
""";
var userPrompt = $"""
分类名称:{category.Name}
分类类型:{typeText}
请为这个分类生成 5 个精美的、风格各异的彩色 SVG 图标。
确保每个图标都有独特的视觉特征,不会与其他图标混淆。
返回格式(纯 JSON 数组,无其他内容):
["<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>"]
""";
var response = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 60 * 10);
if (string.IsNullOrWhiteSpace(response))
{
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", category.Name);
return;
}
// 验证返回的是有效的 JSON 数组
try
{
var icons = JsonSerializer.Deserialize<List<string>>(response);
if (icons == null || icons.Count != 5)
{
logger.LogWarning("AI 返回的图标数量不正确期望5个分类: {CategoryName}", category.Name);
return;
}
// 保存图标到数据库
category.Icon = response;
await categoryRepository.UpdateAsync(category);
logger.LogInformation("成功为分类 {CategoryName}(ID:{CategoryId}) 生成并保存了 5 个图标",
category.Name, category.Id);
}
catch (JsonException ex)
{
logger.LogError(ex, "解析 AI 返回的图标数据失败,分类: {CategoryName},响应内容: {Response}",
category.Name, response);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
namespace Service; namespace Service.Message;
public interface IMessageService public interface IMessageService
{ {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
namespace Service; namespace Service.Transaction;
/// <summary> /// <summary>
/// 周期性账单服务接口 /// 周期性账单服务接口
@@ -108,6 +108,11 @@ public class TransactionPeriodicService(
/// </summary> /// </summary>
private bool ShouldExecuteToday(TransactionPeriodic bill) private bool ShouldExecuteToday(TransactionPeriodic bill)
{ {
if (!bill.IsEnabled)
{
return false;
}
var today = DateTime.Today; var today = DateTime.Today;
// 如果从未执行过,需要执行 // 如果从未执行过,需要执行
@@ -144,7 +149,7 @@ public class TransactionPeriodicService(
var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
.Where(d => d >= 0 && d <= 6) .Where(d => d is >= 0 and <= 6)
.ToList(); .ToList();
return executeDays.Contains(dayOfWeek); return executeDays.Contains(dayOfWeek);
@@ -160,7 +165,7 @@ public class TransactionPeriodicService(
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
.Where(d => d >= 1 && d <= 31) .Where(d => d is >= 1 and <= 31)
.ToList(); .ToList();
// 如果当前为月末,且配置中有大于当月天数的日期,则也执行 // 如果当前为月末,且配置中有大于当月天数的日期,则也执行
@@ -223,7 +228,7 @@ public class TransactionPeriodicService(
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
.Where(d => d >= 0 && d <= 6) .Where(d => d is >= 0 and <= 6)
.OrderBy(d => d) .OrderBy(d => d)
.ToList(); .ToList();
@@ -253,7 +258,7 @@ public class TransactionPeriodicService(
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
.Where(d => d >= 1 && d <= 31) .Where(d => d is >= 1 and <= 31)
.OrderBy(d => d) .OrderBy(d => d)
.ToList(); .ToList();

View File

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

View File

@@ -1,9 +1,21 @@
<template> <template>
<van-config-provider :theme="theme" class="app-provider"> <van-config-provider
:theme="theme"
class="app-provider"
>
<div class="app-root"> <div class="app-root">
<RouterView /> <RouterView />
<van-tabbar v-show="showTabbar" v-model="active"> <van-tabbar
<van-tabbar-item name="ccalendar" icon="notes" to="/calendar"> 日历 </van-tabbar-item> v-show="showTabbar"
v-model="active"
>
<van-tabbar-item
name="ccalendar"
icon="notes"
to="/calendar"
>
日历
</van-tabbar-item>
<van-tabbar-item <van-tabbar-item
name="statistics" name="statistics"
icon="chart-trending-o" icon="chart-trending-o"
@@ -29,12 +41,28 @@
> >
预算 预算
</van-tabbar-item> </van-tabbar-item>
<van-tabbar-item name="setting" icon="setting" to="/setting"> 设置 </van-tabbar-item> <van-tabbar-item
name="setting"
icon="setting"
to="/setting"
>
设置
</van-tabbar-item>
</van-tabbar> </van-tabbar>
<GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess" /> <GlobalAddBill
v-if="isShowAddBill"
@success="handleAddTransactionSuccess"
/>
<div v-if="needRefresh" class="update-toast" @click="updateServiceWorker"> <div
<van-icon name="upgrade" class="update-icon" /> v-if="needRefresh"
class="update-toast"
@click="updateServiceWorker"
>
<van-icon
name="upgrade"
class="update-icon"
/>
<span>新版本可用点击刷新</span> <span>新版本可用点击刷新</span>
</div> </div>
</div> </div>
@@ -149,11 +177,10 @@ const setActive = (path) => {
return 'statistics' return 'statistics'
} }
})() })()
console.log(active.value, path)
} }
const isShowAddBill = computed(() => { const isShowAddBill = computed(() => {
return route.path === '/' || route.path === '/balance' || route.path === '/message' return route.path === '/' || route.path === '/balance' || route.path === '/message' || route.path === '/calendar'
}) })
onUnmounted(() => { onUnmounted(() => {

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => { export const useCounterStore = defineStore('counter', () => {
const count = ref(0) const count = ref(0)
const doubleCount = computed(() => count.value * 2) const doubleCount = computed(() => count.value * 2)
function increment() { function increment () {
count.value++ count.value++
} }

60
Web/src/views/AGENTS.md Normal file
View File

@@ -0,0 +1,60 @@
# FRONTEND VIEWS KNOWLEDGE BASE
**Generated:** 2026-01-28
**Parent:** EmailBill/AGENTS.md
## OVERVIEW
Vue 3 views using Composition API with Vant UI components for mobile-first budget tracking.
## STRUCTURE
```
Web/src/views/
├── BudgetView.vue # Main budget management
├── TransactionsRecord.vue # Transaction list and CRUD
├── StatisticsView.vue # Charts and analytics
├── LoginView.vue # Authentication
├── CalendarView.vue # Calendar-based viewing
├── Classification* # Transaction classification views
│ ├── ClassificationSmart.vue # AI-powered classification
│ ├── ClassificationEdit.vue # Manual classification
│ ├── ClassificationBatch.vue # Batch operations
│ └── ClassificationNLP.vue # NLP classification
├── BillAnalysisView.vue # Bill analysis
├── SettingView.vue # App settings
├── MessageView.vue # Message management
├── EmailRecord.vue # Email records
└── PeriodicRecord.vue # Periodic transactions
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Budget management | BudgetView.vue | Main budget interface |
| Transactions | TransactionsRecord.vue | CRUD operations |
| Statistics | StatisticsView.vue | Charts, analytics |
| Classification | Classification* | Transaction categorization |
| Authentication | LoginView.vue | User login flow |
| Settings | SettingView.vue | App configuration |
| Email features | EmailRecord.vue | Email integration |
## CONVENTIONS
- Vue 3 Composition API with `<script setup lang="ts">`
- Vant UI components: `<van-*>`
- Mobile-first responsive design
- SCSS with BEM naming convention
- Pinia for state management
- Vue Router for navigation
## ANTI-PATTERNS (THIS LAYER)
- Never use Options API (always Composition API)
- Don't access APIs directly (use api/ modules)
- Avoid inline styles (use SCSS modules)
- No synchronous API calls
- Don't mutate props directly
## UNIQUE STYLES
- Chinese interface labels for business concepts
- Mobile-optimized layouts with Vant components
- Integration with backend API via api/ modules
- Real-time data updates via Pinia stores
- Gesture interactions for mobile users

View File

@@ -1,7 +1,10 @@
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<!-- 顶部导航栏 --> <!-- 顶部导航栏 -->
<van-nav-bar title="账单" placeholder> <van-nav-bar
title="账单"
placeholder
>
<template #right> <template #right>
<van-button <van-button
v-if="tabActive === 'email'" v-if="tabActive === 'email'"
@@ -20,15 +23,38 @@
/> />
</template> </template>
</van-nav-bar> </van-nav-bar>
<van-tabs v-model:active="tabActive" type="card" style="margin: 12px 0 2px 0"> <van-tabs
<van-tab title="账单" name="balance" /> v-model:active="tabActive"
<van-tab title="邮件" name="email" /> type="card"
<van-tab title="消息" name="message" /> style="margin: 12px 0 2px 0"
>
<van-tab
title="账单"
name="balance"
/>
<van-tab
title="邮件"
name="email"
/>
<van-tab
title="消息"
name="message"
/>
</van-tabs> </van-tabs>
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef" /> <TransactionsRecord
<EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" /> v-if="tabActive === 'balance'"
<MessageView v-else-if="tabActive === 'message'" ref="messageViewRef" :is-component="true" /> ref="transactionsRecordRef"
/>
<EmailRecord
v-else-if="tabActive === 'email'"
ref="emailRecordRef"
/>
<MessageView
v-else-if="tabActive === 'message'"
ref="messageViewRef"
:is-component="true"
/>
</div> </div>
</template> </template>

View File

@@ -141,7 +141,11 @@ const quickQuestions = [
// 返回 // 返回
const onClickLeft = () => { const onClickLeft = () => {
if (window.history.length > 1) {
router.back() router.back()
} else {
router.replace('/')
}
} }
// 点击提示词按钮 // 点击提示词按钮

View File

@@ -59,6 +59,7 @@
:overall-stats="overallStats" :overall-stats="overallStats"
:budgets="expenseBudgets" :budgets="expenseBudgets"
:active-tab="activeTab" :active-tab="activeTab"
:selected-date="selectedDate"
/> />
</div> </div>
</van-tab> </van-tab>
@@ -72,12 +73,13 @@
:overall-stats="overallStats" :overall-stats="overallStats"
:budgets="incomeBudgets" :budgets="incomeBudgets"
:active-tab="activeTab" :active-tab="activeTab"
:selected-date="selectedDate"
/> />
</div> </div>
</van-tab> </van-tab>
<van-tab <van-tab
title="存款" title="计划"
:name="BudgetCategory.Savings" :name="BudgetCategory.Savings"
> >
<van-pull-refresh <van-pull-refresh
@@ -108,7 +110,7 @@
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="label"> <div class="label">
目标 计划存款
</div> </div>
<div class="value"> <div class="value">
¥{{ formatMoney(budget.limit) }} ¥{{ formatMoney(budget.limit) }}
@@ -501,6 +503,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { showToast, showConfirmDialog } from 'vant' import { showToast, showConfirmDialog } from 'vant'
import dayjs from 'dayjs'
import { import {
getBudgetList, getBudgetList,
deleteBudget, deleteBudget,
@@ -520,7 +523,7 @@ const activeTab = ref(BudgetCategory.Expense)
const selectedDate = ref(new Date()) const selectedDate = ref(new Date())
const showDatePicker = ref(false) const showDatePicker = ref(false)
const minDate = new Date(2020, 0, 1) const minDate = new Date(2020, 0, 1)
const maxDate = new Date(2030, 11, 31) const maxDate = new Date()
const pickerDate = ref([ const pickerDate = ref([
selectedDate.value.getFullYear().toString(), selectedDate.value.getFullYear().toString(),
(selectedDate.value.getMonth() + 1).toString().padStart(2, '0') (selectedDate.value.getMonth() + 1).toString().padStart(2, '0')
@@ -597,33 +600,9 @@ const onConfirmDate = ({ selectedValues }) => {
showDatePicker.value = false showDatePicker.value = false
} }
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 () => { const fetchBudgetList = async () => {
try { try {
const res = await getBudgetList(selectedDate.value.toISOString()) const res = await getBudgetList(dayjs(selectedDate.value).format('YYYY-MM-DD'))
if (res.success) { if (res.success) {
const data = res.data || [] const data = res.data || []
expenseBudgets.value = data.filter((b) => b.category === BudgetCategory.Expense) expenseBudgets.value = data.filter((b) => b.category === BudgetCategory.Expense)
@@ -647,7 +626,10 @@ const onRefresh = async () => {
const fetchCategoryStats = async () => { const fetchCategoryStats = async () => {
try { try {
const res = await getCategoryStats(activeTab.value, selectedDate.value.toISOString()) const res = await getCategoryStats(
activeTab.value,
dayjs(selectedDate.value).format('YYYY-MM-DD')
)
if (res.success) { if (res.success) {
// 转换后端返回的数据格式为前端需要的格式 // 转换后端返回的数据格式为前端需要的格式
const data = res.data const data = res.data
@@ -657,14 +639,16 @@ const fetchCategoryStats = async () => {
current: data.month?.current || 0, current: data.month?.current || 0,
limit: data.month?.limit || 0, limit: data.month?.limit || 0,
count: data.month?.count || 0, count: data.month?.count || 0,
trend: data.month?.trend || [] trend: data.month?.trend || [],
description: data.month?.description || ''
}, },
year: { year: {
rate: data.year?.rate?.toFixed(1) || '0.0', rate: data.year?.rate?.toFixed(1) || '0.0',
current: data.year?.current || 0, current: data.year?.current || 0,
limit: data.year?.limit || 0, limit: data.year?.limit || 0,
count: data.year?.count || 0, count: data.year?.count || 0,
trend: data.year?.trend || [] trend: data.year?.trend || [],
description: data.year?.description || ''
} }
} }
} }
@@ -679,7 +663,10 @@ const fetchUncoveredCategories = async () => {
return return
} }
try { try {
const res = await getUncoveredCategories(activeTab.value, selectedDate.value.toISOString()) const res = await getUncoveredCategories(
activeTab.value,
dayjs(selectedDate.value).format('YYYY-MM-DD')
)
if (res.success) { if (res.success) {
uncoveredCategories.value = res.data || [] uncoveredCategories.value = res.data || []
} }
@@ -795,7 +782,7 @@ const getProgressColor = (budget) => {
const showArchiveSummary = async () => { const showArchiveSummary = async () => {
try { try {
const res = await getArchiveSummary(selectedDate.value.toISOString()) const res = await getArchiveSummary(dayjs(selectedDate.value).format('YYYY-MM-DD'))
if (res.success) { if (res.success) {
archiveSummary.value = res.data || '' archiveSummary.value = res.data || ''
showSummaryPopup.value = true showSummaryPopup.value = true

View File

@@ -0,0 +1,630 @@
<template>
<div
class="calendar-v2"
:data-theme="theme"
>
<!-- 头部 -->
<header class="calendar-header">
<div class="header-content">
<h1 class="header-title">
{{ currentMonth }}
</h1>
</div>
<button
class="notif-btn"
aria-label="通知"
>
<van-icon name="bell" />
</button>
</header>
<!-- 日历容器 -->
<div class="calendar-container">
<!-- 星期标题 -->
<div class="week-days">
<span
v-for="day in weekDays"
:key="day"
class="week-day"
>{{ day }}</span>
</div>
<!-- 日历网格 -->
<div class="calendar-grid">
<div
v-for="(week, weekIndex) in calendarWeeks"
:key="weekIndex"
class="calendar-week"
>
<div
v-for="day in week"
:key="day.date"
class="day-cell"
@click="onDayClick(day)"
>
<div
class="day-number"
:class="{
'day-today': day.isToday,
'day-selected': day.isSelected,
'day-has-data': day.hasData,
'day-over-limit': day.isOverLimit,
'day-other-month': !day.isCurrentMonth
}"
>
{{ day.dayNumber }}
</div>
<div
v-if="day.amount"
class="day-amount"
:class="{ 'amount-over': day.isOverLimit }"
>
{{ day.amount }}
</div>
</div>
</div>
</div>
</div>
<!-- 每日统计 -->
<div class="daily-stats">
<div class="stats-header">
<h2 class="stats-title">
Daily Stats
</h2>
<span class="stats-date">{{ selectedDateFormatted }}</span>
</div>
<div class="stats-card">
<div class="stats-row">
<span class="stats-label">Total Spent</span>
<div class="stats-badge">
Daily Limit: {{ dailyLimit }}
</div>
</div>
<div class="stats-value">
¥ {{ totalSpent }}
</div>
</div>
</div>
<!-- 交易列表 -->
<div class="transactions">
<div class="txn-header">
<h2 class="txn-title">
Transactions
</h2>
<div class="txn-actions">
<div class="txn-badge badge-success">
{{ transactionCount }} Items
</div>
<button class="smart-btn">
<van-icon name="star-o" />
<span>Smart</span>
</button>
</div>
</div>
<!-- 交易卡片 -->
<div class="txn-list">
<div
v-for="txn in transactions"
:key="txn.id"
class="txn-card"
@click="onTransactionClick(txn)"
>
<div
class="txn-icon"
:style="{ backgroundColor: txn.iconBg }"
>
<van-icon
:name="txn.icon"
:color="txn.iconColor"
/>
</div>
<div class="txn-content">
<div class="txn-name">
{{ txn.name }}
</div>
<div class="txn-time">
{{ txn.time }}
</div>
</div>
<div class="txn-amount">
{{ txn.amount }}
</div>
</div>
</div>
</div>
<!-- 底部安全距离 -->
<div class="bottom-spacer" />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
// 当前主题
const theme = ref('light') // 'light' | 'dark'
// 星期标题
const weekDays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
// 当前日期
const currentDate = ref(new Date())
const selectedDate = ref(new Date())
// 当前月份格式化
const currentMonth = computed(() => {
return currentDate.value.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long'
})
})
// 选中日期格式化
const selectedDateFormatted = computed(() => {
return selectedDate.value.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
// 生成日历数据
const calendarWeeks = computed(() => {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth()
// 获取当月第一天
const firstDay = new Date(year, month, 1)
// 获取当月最后一天
const lastDay = new Date(year, month + 1, 0)
// 获取第一天是星期几 (0=Sunday, 调整为 0=Monday)
let startDayOfWeek = firstDay.getDay() - 1
if (startDayOfWeek === -1) {startDayOfWeek = 6}
const weeks = []
let currentWeek = []
// 填充上月日期
for (let i = 0; i < startDayOfWeek; i++) {
const date = new Date(year, month, -(startDayOfWeek - i - 1))
currentWeek.push(createDayObject(date, false))
}
// 填充当月日期
for (let day = 1; day <= lastDay.getDate(); day++) {
const date = new Date(year, month, day)
currentWeek.push(createDayObject(date, true))
if (currentWeek.length === 7) {
weeks.push(currentWeek)
currentWeek = []
}
}
// 填充下月日期
if (currentWeek.length > 0) {
const remainingDays = 7 - currentWeek.length
for (let i = 1; i <= remainingDays; i++) {
const date = new Date(year, month + 1, i)
currentWeek.push(createDayObject(date, false))
}
weeks.push(currentWeek)
}
return weeks
})
// 创建日期对象
const createDayObject = (date, isCurrentMonth) => {
const today = new Date()
const isToday =
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
const isSelected =
date.getDate() === selectedDate.value.getDate() &&
date.getMonth() === selectedDate.value.getMonth() &&
date.getFullYear() === selectedDate.value.getFullYear()
// 模拟数据 - 实际应该从 API 获取
const mockData = getMockDataForDate(date)
return {
date: date.getTime(),
dayNumber: date.getDate(),
isCurrentMonth,
isToday,
isSelected,
hasData: mockData.hasData,
amount: mockData.amount,
isOverLimit: mockData.isOverLimit
}
}
// 模拟数据获取
const getMockDataForDate = (date) => {
const day = date.getDate()
// 模拟一些有数据的日期
if (day >= 4 && day <= 28 && date.getMonth() === currentDate.value.getMonth()) {
const amounts = [128, 45, 230, 12, 88, 223, 15, 34, 120, 56, 442]
const amount = amounts[day % amounts.length]
return {
hasData: true,
amount: amount || '',
isOverLimit: amount > 200 // 超过限额标红
}
}
return { hasData: false, amount: '', isOverLimit: false }
}
// 统计数据
const dailyLimit = ref('2500')
const totalSpent = ref('1,248.50')
const transactionCount = computed(() => transactions.value.length)
// 交易列表数据
const transactions = ref([
{
id: 1,
name: 'Lunch',
time: '12:30 PM',
amount: '-58.00',
icon: 'star',
iconColor: '#FF6B6B',
iconBg: '#FFFFFF'
},
{
id: 2,
name: 'Coffee',
time: '08:15 AM',
amount: '-24.50',
icon: 'coffee-o',
iconColor: '#FCD34D',
iconBg: '#FFFFFF'
}
])
// 点击日期
const onDayClick = (day) => {
if (!day.isCurrentMonth) {return}
selectedDate.value = new Date(day.date)
// TODO: 加载选中日期的数据
console.log('Selected date:', day)
}
// 点击交易
const onTransactionClick = (txn) => {
console.log('Transaction clicked:', txn)
// TODO: 打开交易详情
}
// 切换主题
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
// 暴露切换主题方法供外部调用
defineExpose({
toggleTheme
})
</script>
<style scoped>
@import '@/assets/theme.css';
.calendar-v2 {
min-height: 100vh;
background-color: var(--bg-primary);
font-family: var(--font-primary);
color: var(--text-primary);
}
/* ========== 头部 ========== */
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 24px;
gap: 4px;
}
.header-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-title {
font-family: var(--font-primary);
font-size: var(--font-xl);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin: 0;
}
.notif-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background-color: var(--bg-button);
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.notif-btn:active {
opacity: 0.7;
}
/* ========== 日历容器 ========== */
.calendar-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
}
.week-days {
display: flex;
justify-content: space-between;
}
.week-day {
width: 44px;
text-align: center;
font-size: var(--font-base);
font-weight: var(--font-semibold);
color: var(--text-tertiary);
}
.calendar-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.calendar-week {
display: flex;
justify-content: space-between;
}
.day-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 44px;
cursor: pointer;
}
.day-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 16px;
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-primary);
transition: all 0.2s;
}
.day-number.day-has-data {
background-color: var(--bg-tertiary);
}
.day-number.day-selected {
background-color: var(--accent-primary);
color: #FFFFFF;
}
.day-number.day-other-month {
opacity: 0.3;
}
.day-amount {
font-size: var(--font-xs);
font-weight: var(--font-medium);
color: var(--text-tertiary);
}
.day-amount.amount-over {
color: var(--accent-danger);
}
/* ========== 统计卡片 ========== */
.daily-stats {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-title {
font-family: var(--font-display);
font-size: var(--font-xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
}
.stats-date {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.stats-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
background-color: var(--bg-secondary);
border-radius: var(--radius-lg);
}
.stats-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-label {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.stats-badge {
padding: 6px 10px;
background-color: var(--accent-warning-bg);
color: var(--accent-warning);
font-size: var(--font-sm);
font-weight: var(--font-semibold);
border-radius: var(--radius-sm);
}
.stats-value {
font-family: var(--font-display);
font-size: var(--font-3xl);
font-weight: var(--font-extrabold);
color: var(--text-primary);
}
/* ========== 交易列表 ========== */
.transactions {
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px;
}
.txn-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.txn-title {
font-family: var(--font-display);
font-size: var(--font-xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
}
.txn-actions {
display: flex;
align-items: center;
gap: 8px;
}
.txn-badge {
padding: 6px 12px;
font-size: var(--font-base);
font-weight: var(--font-semibold);
border-radius: var(--radius-sm);
}
.badge-success {
background-color: var(--accent-success-bg);
color: var(--accent-success);
}
.smart-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: var(--accent-info-bg);
color: var(--accent-info);
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-base);
font-weight: var(--font-semibold);
cursor: pointer;
transition: opacity 0.2s;
}
.smart-btn:active {
opacity: 0.7;
}
.txn-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.txn-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: opacity 0.2s;
}
.txn-card:active {
opacity: 0.7;
}
.txn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.txn-content {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.txn-name {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.txn-time {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-tertiary);
}
.txn-amount {
font-size: var(--font-lg);
font-weight: var(--font-bold);
color: var(--text-primary);
}
/* 底部安全距离 */
.bottom-spacer {
height: calc(60px + env(safe-area-inset-bottom, 0px));
}
</style>

View File

@@ -1,4 +1,4 @@
<template> <template>
<div class="page-container calendar-container"> <div class="page-container calendar-container">
<van-calendar <van-calendar
title="日历" title="日历"
@@ -11,8 +11,6 @@
@select="onDateSelect" @select="onDateSelect"
/> />
<ContributionHeatmap ref="heatmapRef" />
<!-- 底部安全距离 --> <!-- 底部安全距离 -->
<div style="height: calc(60px + env(safe-area-inset-bottom, 0px))" /> <div style="height: calc(60px + env(safe-area-inset-bottom, 0px))" />
@@ -58,7 +56,6 @@ import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue' import TransactionDetail from '@/components/TransactionDetail.vue'
import SmartClassifyButton from '@/components/SmartClassifyButton.vue' import SmartClassifyButton from '@/components/SmartClassifyButton.vue'
import PopupContainer from '@/components/PopupContainer.vue' import PopupContainer from '@/components/PopupContainer.vue'
import ContributionHeatmap from '@/components/ContributionHeatmap.vue'
const dailyStatistics = ref({}) const dailyStatistics = ref({})
const listVisible = ref(false) const listVisible = ref(false)
@@ -68,23 +65,14 @@ const currentTransaction = ref(null)
const listLoading = ref(false) const listLoading = ref(false)
const selectedDate = ref(null) const selectedDate = ref(null)
const selectedDateText = ref('') const selectedDateText = ref('')
const heatmapRef = ref(null)
// 设置日历可选范围(例如:过去2年到未来1年 // 设置日历可选范围(例如:过去1年到当前月底
const minDate = new Date(new Date().getFullYear() - 2, 0, 1) // 2年前的1月1日 const minDate = new Date(new Date().getFullYear() - 1, 0, 1) // 1年前的1月1日
const maxDate = new Date(new Date().getFullYear() + 1, 11, 31) // 明年12月31日 let maxDate = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0) // 当前月的最后一天
// 如果当前日超过20号则将最大日期设置为下个月月底方便用户查看和选择
onMounted(async () => { if (new Date().getDate() > 20) {
await nextTick() maxDate = new Date(new Date().getFullYear(), new Date().getMonth() + 2, 0)
setTimeout(() => { }
// 计算页面高度滚动3/4高度以显示更多日期
const height = document.querySelector('.calendar-container').clientHeight * 0.43
document.querySelector('.van-calendar__body').scrollBy({
top: -height,
behavior: 'smooth'
})
}, 300)
})
// 获取日历统计数据 // 获取日历统计数据
const fetchDailyStatistics = async (year, month) => { const fetchDailyStatistics = async (year, month) => {
@@ -96,7 +84,6 @@ const fetchDailyStatistics = async (year, month) => {
// 将数组转换为对象key为日期 // 将数组转换为对象key为日期
const statsMap = {} const statsMap = {}
response.data.forEach((item) => { response.data.forEach((item) => {
console.warn(item)
statsMap[item.date] = { statsMap[item.date] = {
count: item.count, count: item.count,
amount: (item.income - item.expense).toFixed(1) amount: (item.income - item.expense).toFixed(1)
@@ -280,7 +267,6 @@ const onGlobalTransactionDeleted = () => {
} }
const now = selectedDate.value || new Date() const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1) fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
heatmapRef.value?.refresh()
} }
window.addEventListener && window.addEventListener &&
@@ -298,7 +284,6 @@ const onGlobalTransactionsChanged = () => {
} }
const now = selectedDate.value || new Date() const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1) fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
heatmapRef.value?.refresh()
} }
window.addEventListener && window.addEventListener &&

View File

@@ -15,7 +15,10 @@
</div> </div>
<!-- 分组列表 --> <!-- 分组列表 -->
<van-empty v-if="!hasData && finished" description="暂无数据" /> <van-empty
v-if="!hasData && finished"
description="暂无数据"
/>
<van-list <van-list
v-model:loading="listLoading" v-model:loading="listLoading"
@@ -106,7 +109,11 @@ const onLoad = async () => {
// 返回上一页 // 返回上一页
const handleBack = () => { const handleBack = () => {
if (window.history.length > 1) {
router.back() router.back()
} else {
router.replace('/')
}
} }
// 页面加载 // 页面加载

View File

@@ -10,7 +10,10 @@
<div class="scroll-content"> <div class="scroll-content">
<!-- 第一层选择交易类型 --> <!-- 第一层选择交易类型 -->
<div v-if="currentLevel === 0" class="level-container"> <div
v-if="currentLevel === 0"
class="level-container"
>
<van-cell-group inset> <van-cell-group inset>
<van-cell <van-cell
v-for="type in typeOptions" v-for="type in typeOptions"
@@ -23,22 +26,70 @@
</div> </div>
<!-- 第二层分类列表 --> <!-- 第二层分类列表 -->
<div v-else class="level-container"> <div
v-else
class="level-container"
>
<!-- 面包屑导航 --> <!-- 面包屑导航 -->
<div class="breadcrumb"> <div class="breadcrumb">
<van-tag type="primary" closeable style="margin-left: 16px" @close="handleBackToRoot"> <van-tag
type="primary"
closeable
style="margin-left: 16px"
@close="handleBackToRoot"
>
{{ currentTypeName }} {{ currentTypeName }}
</van-tag> </van-tag>
</div> </div>
<!-- 分类列表 --> <!-- 分类列表 -->
<van-empty v-if="categories.length === 0" description="暂无分类" /> <van-empty
v-if="categories.length === 0"
description="暂无分类"
/>
<van-cell-group v-else inset> <van-cell-group
<van-swipe-cell v-for="category in categories" :key="category.id"> v-else
<van-cell :title="category.name" is-link @click="handleEdit(category)" /> inset
>
<van-swipe-cell
v-for="category in categories"
:key="category.id"
>
<van-cell :title="category.name">
<template #icon>
<div
v-if="category.icon"
class="category-icon"
v-html="parseIcon(category.icon)"
/>
</template>
<template #default>
<div class="category-actions">
<van-button
size="small"
type="primary"
plain
@click="handleIconSelect(category)"
>
选择图标
</van-button>
<van-button
size="small"
@click="handleEditOld(category)"
>
编辑
</van-button>
</div>
</template>
</van-cell>
<template #right> <template #right>
<van-button square type="danger" text="删除" @click="handleDelete(category)" /> <van-button
square
type="danger"
text="删除"
@click="handleDelete(category)"
/>
</template> </template>
</van-swipe-cell> </van-swipe-cell>
</van-cell-group> </van-cell-group>
@@ -49,7 +100,12 @@
<div class="bottom-button"> <div class="bottom-button">
<!-- 新增分类按钮 --> <!-- 新增分类按钮 -->
<van-button type="primary" size="large" icon="plus" @click="handleAddCategory"> <van-button
type="primary"
size="large"
icon="plus"
@click="handleAddCategory"
>
新增分类 新增分类
</van-button> </van-button>
</div> </div>
@@ -97,6 +153,52 @@
message="删除后无法恢复,确定要删除吗?" message="删除后无法恢复,确定要删除吗?"
@confirm="handleConfirmDelete" @confirm="handleConfirmDelete"
/> />
<!-- 图标选择对话框 -->
<van-dialog
v-model:show="showIconDialog"
title="选择图标"
show-cancel-button
@confirm="handleConfirmIconSelect"
>
<div class="icon-selector">
<div
v-if="currentCategory && currentCategory.icon"
class="icon-list"
>
<div
v-for="(icon, index) in parseIconArray(currentCategory.icon)"
:key="index"
class="icon-item"
:class="{ active: selectedIconIndex === index }"
@click="selectedIconIndex = index"
>
<div
class="icon-preview"
v-html="icon"
/>
</div>
</div>
<div
v-else
class="empty-icons"
>
<van-empty description="暂无图标" />
</div>
<div class="icon-actions">
<van-button
type="primary"
size="small"
:loading="isGeneratingIcon"
:disabled="isGeneratingIcon"
@click="handleGenerateIcon"
>
{{ isGeneratingIcon ? 'AI生成中...' : '生成新图标' }}
</van-button>
</div>
</div>
</van-dialog>
</div> </div>
</div> </div>
</template> </template>
@@ -109,7 +211,9 @@ import {
getCategoryList, getCategoryList,
createCategory, createCategory,
deleteCategory, deleteCategory,
updateCategory updateCategory,
generateIcon,
updateSelectedIcon
} from '@/api/transactionCategory' } from '@/api/transactionCategory'
const router = useRouter() const router = useRouter()
@@ -151,6 +255,12 @@ const editForm = ref({
name: '' name: ''
}) })
// 图标选择对话框
const showIconDialog = ref(false)
const currentCategory = ref(null) // 当前正在编辑图标的分类
const selectedIconIndex = ref(0)
const isGeneratingIcon = ref(false)
// 计算导航栏标题 // 计算导航栏标题
const navTitle = computed(() => { const navTitle = computed(() => {
if (currentLevel.value === 0) { if (currentLevel.value === 0) {
@@ -202,7 +312,11 @@ const handleBack = () => {
currentType.value = null currentType.value = null
categories.value = [] categories.value = []
} else { } else {
if (window.history.length > 1) {
router.back() router.back()
} else {
router.replace('/')
}
} }
} }
@@ -271,6 +385,98 @@ const handleEdit = (category) => {
showEditDialog.value = true showEditDialog.value = true
} }
/**
* 打开图标选择器
*/
const handleIconSelect = (category) => {
currentCategory.value = category
selectedIconIndex.value = 0
showIconDialog.value = true
}
/**
* 生成新图标
*/
const handleGenerateIcon = async () => {
if (!currentCategory.value) {
return
}
try {
isGeneratingIcon.value = true
showLoadingToast({
message: 'AI正在生成图标...',
forbidClick: true,
duration: 0
})
const { success, data, message } = await generateIcon(currentCategory.value.id)
if (success) {
showSuccessToast('图标生成成功')
// 重新加载分类列表以获取最新的图标
await loadCategories()
// 更新当前分类引用
const updated = categories.value.find((c) => c.id === currentCategory.value.id)
if (updated) {
currentCategory.value = updated
}
} else {
showToast(message || '生成图标失败')
}
} catch (error) {
console.error('生成图标失败:', error)
showToast('生成图标失败: ' + (error.message || '未知错误'))
} finally {
isGeneratingIcon.value = false
closeToast()
}
}
/**
* 确认选择图标
*/
const handleConfirmIconSelect = async () => {
if (!currentCategory.value) {return}
try {
showLoadingToast({
message: '保存中...',
forbidClick: true,
duration: 0
})
const { success, message } = await updateSelectedIcon(
currentCategory.value.id,
selectedIconIndex.value
)
if (success) {
showSuccessToast('图标保存成功')
showIconDialog.value = false
await loadCategories()
} else {
showToast(message || '保存失败')
}
} catch (error) {
console.error('保存图标失败:', error)
showToast('保存图标失败: ' + (error.message || '未知错误'))
} finally {
closeToast()
}
}
/**
* 编辑分类
*/
const handleEditOld = (category) => {
editForm.value = {
id: category.id,
name: category.name
}
showEditDialog.value = true
}
/** /**
* 确认编辑 * 确认编辑
*/ */
@@ -354,6 +560,32 @@ const resetAddForm = () => {
} }
} }
/**
* 解析图标数组(第一个图标为当前选中的)
*/
const parseIcon = (iconJson) => {
if (!iconJson) {return ''}
try {
const icons = JSON.parse(iconJson)
return Array.isArray(icons) && icons.length > 0 ? icons[0] : ''
} catch {
return ''
}
}
/**
* 解析图标数组为完整数组
*/
const parseIconArray = (iconJson) => {
if (!iconJson) {return []}
try {
const icons = JSON.parse(iconJson)
return Array.isArray(icons) ? icons : []
} catch {
return []
}
}
onMounted(() => { onMounted(() => {
// 初始化时显示类型选择 // 初始化时显示类型选择
currentLevel.value = 0 currentLevel.value = 0
@@ -374,6 +606,85 @@ onMounted(() => {
cursor: pointer; cursor: pointer;
} }
.category-icon {
width: 24px;
height: 24px;
margin-right: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.category-icon :deep(svg) {
width: 100%;
height: 100%;
fill: currentColor;
}
.category-actions {
display: flex;
gap: 8px;
}
.icon-selector {
padding: 16px;
}
.icon-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.icon-item {
width: 60px;
height: 60px;
border: 2px solid var(--van-border-color);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.icon-item:hover {
border-color: var(--van-primary-color);
background-color: var(--van-primary-color-light);
}
.icon-item.active {
border-color: var(--van-primary-color);
background-color: var(--van-primary-color-light);
box-shadow: 0 2px 8px rgba(25, 137, 250, 0.3);
}
.icon-preview {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-preview :deep(svg) {
width: 100%;
height: 100%;
fill: currentColor;
}
.empty-icons {
padding: 20px 0;
}
.icon-actions {
padding-top: 16px;
border-top: 1px solid var(--van-border-color);
display: flex;
justify-content: center;
}
/* 深色模式 */ /* 深色模式 */
/* @media (prefers-color-scheme: dark) { /* @media (prefers-color-scheme: dark) {
.level-container { .level-container {

View File

@@ -1,6 +1,11 @@
<template> <template>
<div class="page-container-flex classification-nlp"> <div class="page-container-flex classification-nlp">
<van-nav-bar title="自然语言分类" left-text="返回" left-arrow @click-left="onClickLeft" /> <van-nav-bar
title="自然语言分类"
left-text="返回"
left-arrow
@click-left="onClickLeft"
/>
<div class="scroll-content"> <div class="scroll-content">
<!-- 输入区域 --> <!-- 输入区域 -->
@@ -18,18 +23,36 @@
</van-cell-group> </van-cell-group>
<div class="action-buttons"> <div class="action-buttons">
<van-button type="primary" block round :loading="analyzing" @click="handleAnalyze"> <van-button
type="primary"
block
round
:loading="analyzing"
@click="handleAnalyze"
>
分析查询 分析查询
</van-button> </van-button>
</div> </div>
</div> </div>
<!-- 分析结果展示 --> <!-- 分析结果展示 -->
<div v-if="analysisResult" class="result-section"> <div
v-if="analysisResult"
class="result-section"
>
<van-cell-group inset> <van-cell-group inset>
<van-cell title="查询关键词" :value="analysisResult.searchKeyword" /> <van-cell
<van-cell title="AI建议类型" :value="getTypeName(analysisResult.targetType)" /> title="查询关键词"
<van-cell title="AI建议分类" :value="analysisResult.targetClassify" /> :value="analysisResult.searchKeyword"
/>
<van-cell
title="AI建议类型"
:value="getTypeName(analysisResult.targetType)"
/>
<van-cell
title="AI建议分类"
:value="analysisResult.targetClassify"
/>
<van-cell <van-cell
title="找到记录" title="找到记录"
:value="`${analysisResult.records.length} 条`" :value="`${analysisResult.records.length} 条`"
@@ -48,12 +71,30 @@
/> />
<!-- 记录列表弹窗 --> <!-- 记录列表弹窗 -->
<PopupContainer v-model="showRecordsList" title="交易记录列表" height="75%"> <PopupContainer
v-model="showRecordsList"
title="交易记录列表"
height="75%"
>
<div style="background: var(--van-background)"> <div style="background: var(--van-background)">
<!-- 批量操作按钮 --> <!-- 批量操作按钮 -->
<div class="batch-actions"> <div class="batch-actions">
<van-button plain type="primary" size="small" @click="selectAll"> 全选 </van-button> <van-button
<van-button plain type="default" size="small" @click="selectNone"> 全不选 </van-button> plain
type="primary"
size="small"
@click="selectAll"
>
全选
</van-button>
<van-button
plain
type="default"
size="small"
@click="selectNone"
>
全不选
</van-button>
<van-button <van-button
type="success" type="success"
size="small" size="small"
@@ -104,7 +145,11 @@ const showRecordsList = ref(false) // 控制记录列表弹窗
// 返回按钮 // 返回按钮
const onClickLeft = () => { const onClickLeft = () => {
if (window.history.length > 1) {
router.back() router.back()
} else {
router.replace('/')
}
} }
// 将带目标分类的记录转换为普通交易记录格式供列表显示 // 将带目标分类的记录转换为普通交易记录格式供列表显示

View File

@@ -1,8 +1,16 @@
<template> <template>
<div class="page-container-flex smart-classification"> <div class="page-container-flex smart-classification">
<van-nav-bar title="智能分类" left-text="返回" left-arrow @click-left="onClickLeft" /> <van-nav-bar
title="智能分类"
left-text="返回"
left-arrow
@click-left="onClickLeft"
/>
<div class="scroll-content" style="padding-top: 5px"> <div
class="scroll-content"
style="padding-top: 5px"
>
<!-- 统计信息 --> <!-- 统计信息 -->
<div class="stats-info"> <div class="stats-info">
<span class="stats-label">未分类账单 </span> <span class="stats-label">未分类账单 </span>
@@ -110,11 +118,19 @@ const onClickLeft = () => {
message: '有未保存的分类结果,确定要离开吗?' message: '有未保存的分类结果,确定要离开吗?'
}) })
.then(() => { .then(() => {
if (window.history.length > 1) {
router.back() router.back()
} else {
router.replace('/')
}
}) })
.catch(() => {}) .catch(() => {})
} else { } else {
if (window.history.length > 1) {
router.back() router.back()
} else {
router.replace('/')
}
} }
} }

View File

@@ -1,4 +1,4 @@
<template> <template>
<div class="page-container-flex log-view"> <div class="page-container-flex log-view">
<van-nav-bar <van-nav-bar
title="查看日志" title="查看日志"
@@ -28,7 +28,12 @@
<van-dropdown-item <van-dropdown-item
v-model="selectedDate" v-model="selectedDate"
:options="dateOptions" :options="dateOptions"
@change="handleSearch" @change="handleDateChange"
/>
<van-dropdown-item
v-model="selectedClassName"
:options="classNameOptions"
@change="handleClassNameChange"
/> />
</van-dropdown-menu> </van-dropdown-menu>
</div> </div>
@@ -66,6 +71,31 @@
<span class="log-level">{{ log.level }}</span> <span class="log-level">{{ log.level }}</span>
<span class="log-time">{{ formatTime(log.timestamp) }}</span> <span class="log-time">{{ formatTime(log.timestamp) }}</span>
</div> </div>
<div
v-if="log.className || log.methodName"
class="log-source"
>
<span
v-if="log.className"
class="source-class"
>{{ log.className }}</span>
<span
v-if="log.methodName"
class="source-method"
>.{{ log.methodName }}</span>
</div>
<div
v-if="log.requestId"
class="log-request-id"
>
<span class="request-id-label">请求ID:</span>
<span
class="request-id-value"
@click="handleRequestIdClick(log.requestId)"
>
{{ log.requestId }}
</span>
</div>
<div class="log-message"> <div class="log-message">
{{ log.message }} {{ log.message }}
</div> </div>
@@ -90,7 +120,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast } from 'vant' import { showToast } from 'vant'
import { getLogList, getAvailableDates } from '@/api/log' import { getLogList, getAvailableDates, getAvailableClassNames, getLogsByRequestId } from '@/api/log'
const router = useRouter() const router = useRouter()
@@ -110,6 +140,11 @@ const total = ref(0)
const searchKeyword = ref('') const searchKeyword = ref('')
const selectedLevel = ref('') const selectedLevel = ref('')
const selectedDate = ref('') const selectedDate = ref('')
const selectedClassName = ref('')
// requestId 查询模式
const isRequestIdMode = ref(false)
const currentRequestId = ref('')
// 日志级别选项 // 日志级别选项
const levelOptions = ref([ const levelOptions = ref([
@@ -125,11 +160,18 @@ const levelOptions = ref([
// 日期选项 // 日期选项
const dateOptions = ref([{ text: '全部日期', value: '' }]) const dateOptions = ref([{ text: '全部日期', value: '' }])
// 类名选项
const classNameOptions = ref([{ text: '全部类名', value: '' }])
/** /**
* 返回上一页 * 返回上一页
*/ */
const handleBack = () => { const handleBack = () => {
if (window.history.length > 1) {
router.back() router.back()
} else {
router.replace('/')
}
} }
/** /**
@@ -173,6 +215,17 @@ const loadLogs = async (reset = false) => {
finished.value = false finished.value = false
} }
let response
if (isRequestIdMode.value) {
// requestId 查询模式
response = await getLogsByRequestId({
requestId: currentRequestId.value,
pageIndex: pageIndex.value,
pageSize: pageSize.value
})
} else {
// 普通查询模式
const params = { const params = {
pageIndex: pageIndex.value, pageIndex: pageIndex.value,
pageSize: pageSize.value pageSize: pageSize.value
@@ -187,8 +240,12 @@ const loadLogs = async (reset = false) => {
if (selectedDate.value) { if (selectedDate.value) {
params.date = selectedDate.value params.date = selectedDate.value
} }
if (selectedClassName.value) {
params.className = selectedClassName.value
}
const response = await getLogList(params) response = await getLogList(params)
}
if (response.success) { if (response.success) {
const newLogs = response.data || [] const newLogs = response.data || []
@@ -202,12 +259,9 @@ const loadLogs = async (reset = false) => {
total.value = response.total total.value = response.total
// 判断是否还有更多数据 // 判断是否还有更多数据
// total = -1 表示总数未知,此时只根据返回数据量判断
if (total.value === -1) { if (total.value === -1) {
// 如果返回的数据少于请求的数量,说明没有更多了
finished.value = newLogs.length < pageSize.value finished.value = newLogs.length < pageSize.value
} else { } else {
// 如果有明确的总数,则判断是否已加载完全部数据
if (logList.value.length >= total.value || newLogs.length < pageSize.value) { if (logList.value.length >= total.value || newLogs.length < pageSize.value) {
finished.value = true finished.value = true
} else { } else {
@@ -233,7 +287,12 @@ const loadLogs = async (reset = false) => {
* 下拉刷新 * 下拉刷新
*/ */
const onRefresh = async () => { const onRefresh = async () => {
if (isRequestIdMode.value) {
// requestId 模式下刷新,重置为第一页
await loadLogs(true) await loadLogs(true)
} else {
await loadLogs(true)
}
} }
/** /**
@@ -258,6 +317,8 @@ const onLoad = async () => {
* 搜索处理 * 搜索处理
*/ */
const handleSearch = () => { const handleSearch = () => {
isRequestIdMode.value = false
currentRequestId.value = ''
loadLogs(true) loadLogs(true)
} }
@@ -287,6 +348,44 @@ const loadAvailableDates = async () => {
} }
} }
/**
* 加载可用类名列表
*/
const loadAvailableClassNames = async () => {
try {
const params = {}
if (selectedDate.value) {
params.date = selectedDate.value
}
const response = await getAvailableClassNames(params)
if (response.success && response.data) {
const classNames = response.data.map((name) => ({
text: name,
value: name
}))
classNameOptions.value = [{ text: '全部类名', value: '' }, ...classNames]
}
} catch (error) {
console.error('加载类名列表失败:', error)
}
}
/**
* 日期改变时重新加载类名
*/
const handleDateChange = async () => {
selectedClassName.value = ''
await loadAvailableClassNames()
handleSearch()
}
/**
* 类名改变时重新搜索
*/
const handleClassNameChange = () => {
handleSearch()
}
/** /**
* 格式化日期显示 * 格式化日期显示
*/ */
@@ -298,9 +397,50 @@ const formatDate = (dateStr) => {
return dateStr return dateStr
} }
/**
* 处理请求ID点击
*/
const handleRequestIdClick = async (requestId) => {
try {
showToast('正在查询关联日志...')
isRequestIdMode.value = true
currentRequestId.value = requestId
const response = await getLogsByRequestId({
requestId,
pageIndex: 1,
pageSize: 100
})
if (response.success && response.data && response.data.length > 0) {
logList.value = response.data
total.value = response.total
pageIndex.value = 1
// 根据返回数据量判断是否还有更多
if (response.data.length < 100) {
finished.value = true
} else {
finished.value = false
}
showToast(`找到 ${response.total} 条关联日志`)
} else {
showToast('未找到关联日志')
logList.value = []
finished.value = true
}
} catch (error) {
console.error('查询关联日志失败:', error)
showToast('查询失败')
}
}
// 组件挂载时加载数据 // 组件挂载时加载数据
onMounted(() => { onMounted(() => {
loadAvailableDates() loadAvailableDates()
loadAvailableClassNames()
// 不在这里调用 loadLogs让 van-list 的 @load 事件自动触发 // 不在这里调用 loadLogs让 van-list 的 @load 事件自动触发
}) })
</script> </script>
@@ -395,6 +535,43 @@ onMounted(() => {
} }
} }
.log-request-id {
margin: 2px 0;
font-size: 10px;
color: #666;
display: flex;
align-items: center;
}
.log-source {
margin: 2px 0;
font-size: 10px;
color: #666;
display: flex;
align-items: center;
}
.source-class {
font-weight: bold;
color: var(--van-primary-color);
}
.request-id-label {
margin-right: 4px;
font-weight: bold;
}
.request-id-value {
cursor: pointer;
color: var(--van-primary-color);
text-decoration: underline;
word-break: break-all;
}
.request-id-value:hover {
opacity: 0.8;
}
.log-message { .log-message {
color: #323233; color: #323233;
line-height: 1.4; line-height: 1.4;
@@ -471,9 +648,8 @@ onMounted(() => {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
} }
/* @media (prefers-color-scheme: dark) { /* 设置页面容器背景色 */
:deep(.van-dropdown-menu) { :deep(.van-nav-bar) {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); background: transparent !important;
} }
} */
</style> </style>

View File

@@ -1,7 +1,9 @@
<template> <template>
<div class="page-container login-container"> <div class="page-container login-container">
<div class="login-box"> <div class="login-box">
<h1 class="login-title">账单</h1> <h1 class="login-title">
账单
</h1>
<div class="login-form"> <div class="login-form">
<van-field <van-field
v-model="password" v-model="password"

View File

@@ -9,9 +9,16 @@
/> />
<!-- 下拉刷新区域 --> <!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh"> <van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
>
<!-- 加载提示 --> <!-- 加载提示 -->
<van-loading v-if="loading && !periodicList.length" vertical style="padding: 50px 0"> <van-loading
v-if="loading && !periodicList.length"
vertical
style="padding: 50px 0"
>
加载中... 加载中...
</van-loading> </van-loading>
@@ -23,10 +30,18 @@
class="periodic-list" class="periodic-list"
@load="onLoad" @load="onLoad"
> >
<van-cell-group v-for="item in periodicList" :key="item.id" inset class="periodic-item"> <van-cell-group
v-for="item in periodicList"
:key="item.id"
inset
class="periodic-item"
>
<van-swipe-cell> <van-swipe-cell>
<div @click="editPeriodic(item)"> <div @click="editPeriodic(item)">
<van-cell :title="item.reason || '无摘要'" :label="getPeriodicTypeText(item)"> <van-cell
:title="item.reason || '无摘要'"
:label="getPeriodicTypeText(item)"
>
<template #value> <template #value>
<div class="amount-info"> <div class="amount-info">
<span :class="['amount', item.type === 1 ? 'income' : 'expense']"> <span :class="['amount', item.type === 1 ? 'income' : 'expense']">
@@ -35,7 +50,10 @@
</div> </div>
</template> </template>
</van-cell> </van-cell>
<van-cell title="分类" :value="item.classify || '未分类'" /> <van-cell
title="分类"
:value="item.classify || '未分类'"
/>
<van-cell <van-cell
title="下次执行时间" title="下次执行时间"
:value="formatDateTime(item.nextExecuteTime) || '未设置'" :value="formatDateTime(item.nextExecuteTime) || '未设置'"
@@ -77,7 +95,13 @@
<!-- 底部新增按钮 --> <!-- 底部新增按钮 -->
<div class="bottom-button"> <div class="bottom-button">
<van-button type="primary" size="large" round icon="plus" @click="openAddDialog"> <van-button
type="primary"
size="large"
round
icon="plus"
@click="openAddDialog"
>
新增周期账单 新增周期账单
</van-button> </van-button>
</div> </div>
@@ -89,7 +113,10 @@
height="75%" height="75%"
> >
<van-form> <van-form>
<van-cell-group inset title="周期设置"> <van-cell-group
inset
title="周期设置"
>
<van-field <van-field
v-model="form.periodicTypeText" v-model="form.periodicTypeText"
is-link is-link
@@ -150,7 +177,10 @@
/> />
</van-cell-group> </van-cell-group>
<van-cell-group inset title="基本信息"> <van-cell-group
inset
title="基本信息"
>
<van-field <van-field
v-model="form.reason" v-model="form.reason"
name="reason" name="reason"
@@ -170,35 +200,68 @@
type="number" type="number"
:rules="[{ required: true, message: '请输入金额' }]" :rules="[{ required: true, message: '请输入金额' }]"
/> />
<van-field v-model="form.type" name="type" label="类型"> <van-field
v-model="form.type"
name="type"
label="类型"
>
<template #input> <template #input>
<van-radio-group v-model="form.type" direction="horizontal"> <van-radio-group
<van-radio :name="0"> 支出 </van-radio> v-model="form.type"
<van-radio :name="1"> 收入 </van-radio> direction="horizontal"
<van-radio :name="2"> 不计 </van-radio> >
<van-radio :value="0">
支出
</van-radio>
<van-radio :value="1">
收入
</van-radio>
<van-radio :value="2">
不计
</van-radio>
</van-radio-group> </van-radio-group>
</template> </template>
</van-field> </van-field>
<van-field name="classify" label="分类"> <van-field
name="classify"
label="分类"
>
<template #input> <template #input>
<span v-if="!form.classify" style="color: var(--van-gray-5)">请选择交易分类</span> <span
v-if="!form.classify"
style="color: var(--van-gray-5)"
>请选择交易分类</span>
<span v-else>{{ form.classify }}</span> <span v-else>{{ form.classify }}</span>
</template> </template>
</van-field> </van-field>
<!-- 分类选择组件 --> <!-- 分类选择组件 -->
<ClassifySelector v-model="form.classify" :type="form.type" /> <ClassifySelector
v-model="form.classify"
:type="form.type"
/>
</van-cell-group> </van-cell-group>
</van-form> </van-form>
<template #footer> <template #footer>
<van-button round block type="primary" :loading="submitting" @click="submit"> <van-button
round
block
type="primary"
:loading="submitting"
@click="submit"
>
{{ isEdit ? '更新' : '确认添加' }} {{ isEdit ? '更新' : '确认添加' }}
</van-button> </van-button>
</template> </template>
</PopupContainer> </PopupContainer>
<!-- 周期类型选择器 --> <!-- 周期类型选择器 -->
<van-popup v-model:show="showPeriodicTypePicker" position="bottom" round teleport="body"> <van-popup
v-model:show="showPeriodicTypePicker"
position="bottom"
round
teleport="body"
>
<van-picker <van-picker
:columns="periodicTypeColumns" :columns="periodicTypeColumns"
@confirm="onPeriodicTypeConfirm" @confirm="onPeriodicTypeConfirm"
@@ -207,7 +270,12 @@
</van-popup> </van-popup>
<!-- 星期选择器 --> <!-- 星期选择器 -->
<van-popup v-model:show="showWeekdaysPicker" position="bottom" round teleport="body"> <van-popup
v-model:show="showWeekdaysPicker"
position="bottom"
round
teleport="body"
>
<van-picker <van-picker
:columns="weekdaysColumns" :columns="weekdaysColumns"
@confirm="onWeekdaysConfirm" @confirm="onWeekdaysConfirm"
@@ -216,7 +284,12 @@
</van-popup> </van-popup>
<!-- 日期选择器 --> <!-- 日期选择器 -->
<van-popup v-model:show="showMonthDaysPicker" position="bottom" round teleport="body"> <van-popup
v-model:show="showMonthDaysPicker"
position="bottom"
round
teleport="body"
>
<van-picker <van-picker
:columns="monthDaysColumns" :columns="monthDaysColumns"
@confirm="onMonthDaysConfirm" @confirm="onMonthDaysConfirm"
@@ -233,7 +306,9 @@ import { showToast, showConfirmDialog } from 'vant'
import { import {
getPeriodicList, getPeriodicList,
deletePeriodic as deletePeriodicApi, deletePeriodic as deletePeriodicApi,
togglePeriodicEnabled togglePeriodicEnabled,
createPeriodic,
updatePeriodic
} from '@/api/transactionPeriodic' } from '@/api/transactionPeriodic'
import PopupContainer from '@/components/PopupContainer.vue' import PopupContainer from '@/components/PopupContainer.vue'
import ClassifySelector from '@/components/ClassifySelector.vue' import ClassifySelector from '@/components/ClassifySelector.vue'
@@ -365,7 +440,11 @@ const onLoad = () => {
// 返回上一页 // 返回上一页
const handleBack = () => { const handleBack = () => {
if (window.history.length > 1) {
router.back() router.back()
} else {
router.replace('/')
}
} }
// 获取周期类型文本 // 获取周期类型文本
@@ -425,10 +504,10 @@ const editPeriodic = (item) => {
form.id = item.id form.id = item.id
form.reason = item.reason form.reason = item.reason
form.amount = item.amount.toString() form.amount = item.amount.toString()
form.type = item.type form.type = parseInt(item.type)
form.classify = item.classify form.classify = item.classify
form.periodicType = item.periodicType form.periodicType = parseInt(item.periodicType)
form.periodicTypeText = periodicTypeColumns.find((t) => t.value === item.periodicType)?.text || '' form.periodicTypeText = periodicTypeColumns.find((t) => t.value === parseInt(item.periodicType))?.text || ''
// 解析周期配置 // 解析周期配置
if (item.periodicConfig) { if (item.periodicConfig) {
@@ -553,6 +632,105 @@ const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
form.monthDaysText = selectedOptions[0].text form.monthDaysText = selectedOptions[0].text
showMonthDaysPicker.value = false showMonthDaysPicker.value = false
} }
// 提交表单
const submit = async () => {
// 验证基本字段
if (!form.reason.trim()) {
showToast('请输入摘要')
return
}
if (!form.amount) {
showToast('请输入金额')
return
}
if (form.amount < 0) {
showToast('金额必须大于0')
return
}
if (!form.classify) {
showToast('请选择分类')
return
}
// 验证周期配置
if (!form.periodicTypeText) {
showToast('请选择周期类型')
return
}
let periodicConfig = ''
switch (form.periodicType) {
case 0: // 每天
periodicConfig = ''
break
case 1: // 每周
if (form.weekdays.length === 0) {
showToast('请选择星期几')
return
}
periodicConfig = form.weekdays.join(',')
break
case 2: // 每月
if (form.monthDays.length === 0) {
showToast('请选择日期')
return
}
periodicConfig = form.monthDays.join(',')
break
case 3: // 每季度
if (!form.quarterDay) {
showToast('请输入季度开始后第几天')
return
}
periodicConfig = form.quarterDay.toString()
break
case 4: // 每年
if (!form.yearDay) {
showToast('请输入年开始后第几天')
return
}
periodicConfig = form.yearDay.toString()
break
}
submitting.value = true
try {
const requestData = {
periodicType: parseInt(form.periodicType),
periodicConfig: periodicConfig,
amount: parseFloat(form.amount),
type: parseInt(form.type),
classify: form.classify,
reason: form.reason.trim()
}
let response
if (isEdit.value) {
// 更新
requestData.id = form.id
requestData.isEnabled = true // Update API 需要此字段
response = await updatePeriodic(requestData)
} else {
// 创建
response = await createPeriodic(requestData)
}
if (response.success) {
showToast(isEdit.value ? '更新成功' : '添加成功')
dialogVisible.value = false
resetForm()
loadData(true)
} else {
showToast(response.message || (isEdit.value ? '更新失败' : '添加失败'))
}
} catch (error) {
console.error('提交出错:', error)
showToast(isEdit.value ? '更新出错' : '添加出错')
} finally {
submitting.value = false
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -119,7 +119,11 @@ onMounted(() => {
}) })
const onClickLeft = () => { const onClickLeft = () => {
if (window.history.length > 1) {
router.back() router.back()
} else {
router.replace('/')
}
} }
const handleExecute = async (task) => { const handleExecute = async (task) => {

View File

@@ -1,4 +1,4 @@
<template> <template>
<div class="page-container-flex"> <div class="page-container-flex">
<!-- 顶部导航栏 --> <!-- 顶部导航栏 -->
<van-nav-bar <van-nav-bar
@@ -9,7 +9,7 @@
class="nav-date-picker" class="nav-date-picker"
@click="showMonthPicker = true" @click="showMonthPicker = true"
> >
<span>{{ currentYear }}{{ currentMonth }}</span> <span>{{ currentMonth === 0 ? `${currentYear}` : `${currentYear}${currentMonth}` }}</span>
<van-icon name="arrow-down" /> <van-icon name="arrow-down" />
</div> </div>
</template> </template>
@@ -42,23 +42,37 @@
class="statistics-content" class="statistics-content"
> >
<div> <div>
<!-- 趋势统计 --> <!-- 余额卡片 -->
<div <div
class="common-card" class="common-card"
style="padding-bottom: 5px; margin-top: 12px;" style="margin-top: 12px;"
>
<div
class="card-header"
style="padding-bottom: 0;"
> >
<div class="card-header">
<h3 class="card-title"> <h3 class="card-title">
收支趋势 余额
</h3> </h3>
</div> </div>
<!-- 余额金额 -->
<div class="balance-amount">
<span
class="balance-value"
:class="{ 'balance-positive': displayBalance >= 0, 'balance-negative': displayBalance < 0 }"
>
¥{{ formatMoney(displayBalance) }}
</span>
</div>
<!-- 余额变化图表融合收支趋势 -->
<div <div
class="trend-chart" class="balance-chart"
style="height: 240px; padding: 10px 0" style="height: 190px; padding: 0"
> >
<div <div
ref="chartRef" ref="balanceChartRef"
style="width: 100%; height: 100%" style="width: 100%; height: 100%"
/> />
</div> </div>
@@ -152,6 +166,12 @@
<div class="card-header"> <div class="card-header">
<h3 class="card-title"> <h3 class="card-title">
收入 收入
<span
class="income-text"
style="font-size: 13px; margin-left: 4px"
>
¥{{ formatMoney(monthlyData.totalIncome) }}
</span>
</h3> </h3>
<van-tag <van-tag
type="success" type="success"
@@ -220,19 +240,35 @@
</div> </div>
</van-pull-refresh> </van-pull-refresh>
<!-- 月份选择器 --> <!-- 日期选择器 -->
<van-popup <van-popup
v-model:show="showMonthPicker" v-model:show="showMonthPicker"
position="bottom" position="bottom"
round round
teleport="body" teleport="body"
> >
<div class="date-picker-header">
<van-tabs
v-model:active="dateSelectionMode"
line-width="20px"
:ellipsis="false"
>
<van-tab
title="按月"
name="month"
/>
<van-tab
title="按年"
name="year"
/>
</van-tabs>
</div>
<van-date-picker <van-date-picker
v-model="selectedDate" v-model="selectedDate"
title="选择月份" :title="dateSelectionMode === 'year' ? '选择年份' : '选择月份'"
:min-date="minDate" :min-date="minDate"
:max-date="maxDate" :max-date="maxDate"
:columns-type="['year', 'month']" :columns-type="dateSelectionMode === 'year' ? ['year'] : ['year', 'month']"
@confirm="onMonthConfirm" @confirm="onMonthConfirm"
@cancel="showMonthPicker = false" @cancel="showMonthPicker = false"
/> />
@@ -282,7 +318,7 @@ import { ref, computed, onMounted, onActivated, nextTick, watch } from 'vue'
import { onBeforeUnmount } from 'vue' import { onBeforeUnmount } from 'vue'
import { showToast } from 'vant' import { showToast } from 'vant'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getMonthlyStatistics, getCategoryStatistics, getDailyStatistics } from '@/api/statistics' import { getMonthlyStatistics, getCategoryStatistics, getDailyStatistics, getBalanceStatistics } from '@/api/statistics'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { getTransactionList, getTransactionDetail } from '@/api/transactionRecord' import { getTransactionList, getTransactionDetail } from '@/api/transactionRecord'
import TransactionList from '@/components/TransactionList.vue' import TransactionList from '@/components/TransactionList.vue'
@@ -299,6 +335,7 @@ const firstLoading = ref(true)
const refreshing = ref(false) const refreshing = ref(false)
const showMonthPicker = ref(false) const showMonthPicker = ref(false)
const showAllExpense = ref(false) const showAllExpense = ref(false)
const dateSelectionMode = ref('month')
const currentYear = ref(new Date().getFullYear()) const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth() + 1) const currentMonth = ref(new Date().getMonth() + 1)
const selectedDate = ref([ const selectedDate = ref([
@@ -386,10 +423,12 @@ const noneCategoriesView = computed(() => {
// 趋势数据 // 趋势数据
const dailyData = ref([]) const dailyData = ref([])
const chartRef = ref(null) // 余额数据(独立)
const balanceData = ref([])
const pieChartRef = ref(null) const pieChartRef = ref(null)
let chartInstance = null const balanceChartRef = ref(null)
let pieChartInstance = null let pieChartInstance = null
let balanceChartInstance = null
// 日期范围 // 日期范围
const minDate = new Date(2020, 0, 1) const minDate = new Date(2020, 0, 1)
@@ -419,6 +458,50 @@ const isUnclassified = computed(() => {
return selectedClassify.value === '未分类' || selectedClassify.value === '' return selectedClassify.value === '未分类' || selectedClassify.value === ''
}) })
// 当月累积余额
const currentMonthBalance = computed(() => {
if (balanceData.value.length === 0) {
return 0
}
// 获取最后一天的累积余额
return balanceData.value[balanceData.value.length - 1].cumulativeBalance || 0
})
// 显示的动画余额
const displayBalance = ref(0)
// 监听余额变化,执行动画
watch(currentMonthBalance, (newVal, oldVal) => {
if (oldVal === undefined) {
// 初始加载,直接设置,不需要动画
displayBalance.value = newVal
return
}
// 数字跳动动画
const duration = 800 // 动画持续时间(毫秒)
const startValue = oldVal
const endValue = newVal
const startTime = Date.now()
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// 使用缓动函数easeOutQuad
const easeProgress = 1 - (1 - progress) * (1 - progress)
displayBalance.value = Math.round(startValue + (endValue - startValue) * easeProgress)
if (progress < 1) {
requestAnimationFrame(animate)
} else {
displayBalance.value = endValue
}
}
animate()
})
// 格式化金额 // 格式化金额
const formatMoney = (value) => { const formatMoney = (value) => {
if (!value && value !== 0) { if (!value && value !== 0) {
@@ -438,7 +521,7 @@ const onRefresh = async () => {
// 确认月份选择 // 确认月份选择
const onMonthConfirm = ({ selectedValues }) => { const onMonthConfirm = ({ selectedValues }) => {
const newYear = parseInt(selectedValues[0]) const newYear = parseInt(selectedValues[0])
const newMonth = parseInt(selectedValues[1]) const newMonth = dateSelectionMode.value === 'year' ? 0 : parseInt(selectedValues[1])
currentYear.value = newYear currentYear.value = newYear
currentMonth.value = newMonth currentMonth.value = newMonth
@@ -457,7 +540,7 @@ const fetchStatistics = async (showLoading = true) => {
} }
try { try {
await Promise.all([fetchMonthlyData(), fetchCategoryData(), fetchDailyData()]) await Promise.all([fetchMonthlyData(), fetchCategoryData(), fetchDailyData(), fetchBalanceData()])
} catch (error) { } catch (error) {
console.error('获取统计数据失败:', error) console.error('获取统计数据失败:', error)
showToast('获取统计数据失败') showToast('获取统计数据失败')
@@ -466,8 +549,8 @@ const fetchStatistics = async (showLoading = true) => {
firstLoading.value = false firstLoading.value = false
// DOM 更新后渲染图表 // DOM 更新后渲染图表
nextTick(() => { nextTick(() => {
renderChart(dailyData.value)
renderPieChart() renderPieChart()
renderBalanceChart()
}) })
} }
} }
@@ -477,7 +560,7 @@ const fetchMonthlyData = async () => {
try { try {
const response = await getMonthlyStatistics({ const response = await getMonthlyStatistics({
year: currentYear.value, year: currentYear.value,
month: currentMonth.value month: currentMonth.value || 0
}) })
if (response.success && response.data) { if (response.success && response.data) {
@@ -495,7 +578,7 @@ const fetchCategoryData = async () => {
// 获取支出分类 // 获取支出分类
const expenseResponse = await getCategoryStatistics({ const expenseResponse = await getCategoryStatistics({
year: currentYear.value, year: currentYear.value,
month: currentMonth.value, month: currentMonth.value || 0,
type: 0 // 支出 type: 0 // 支出
}) })
@@ -513,7 +596,7 @@ const fetchCategoryData = async () => {
// 获取收入分类 // 获取收入分类
const incomeResponse = await getCategoryStatistics({ const incomeResponse = await getCategoryStatistics({
year: currentYear.value, year: currentYear.value,
month: currentMonth.value, month: currentMonth.value || 0,
type: 1 // 收入 type: 1 // 收入
}) })
@@ -529,7 +612,7 @@ const fetchCategoryData = async () => {
// 获取不计收支分类 // 获取不计收支分类
const noneResponse = await getCategoryStatistics({ const noneResponse = await getCategoryStatistics({
year: currentYear.value, year: currentYear.value,
month: currentMonth.value, month: currentMonth.value || 0,
type: 2 // 不计收支 type: 2 // 不计收支
}) })
@@ -552,7 +635,7 @@ const fetchDailyData = async () => {
try { try {
const response = await getDailyStatistics({ const response = await getDailyStatistics({
year: currentYear.value, year: currentYear.value,
month: currentMonth.value month: currentMonth.value || 0
}) })
if (response.success && response.data) { if (response.success && response.data) {
@@ -560,7 +643,7 @@ const fetchDailyData = async () => {
// 如果不是首次加载即DOM已存在直接渲染 // 如果不是首次加载即DOM已存在直接渲染
if (!firstLoading.value) { if (!firstLoading.value) {
nextTick(() => { nextTick(() => {
renderChart(response.data) renderBalanceChart()
}) })
} }
} }
@@ -570,181 +653,27 @@ const fetchDailyData = async () => {
} }
} }
const renderChart = (data) => { // 获取余额统计数据(独立接口)
if (!chartRef.value) { const fetchBalanceData = async () => {
return try {
} const response = await getBalanceStatistics({
year: currentYear.value,
// 尝试获取DOM上的现有实例 month: currentMonth.value || 0
const existingInstance = echarts.getInstanceByDom(chartRef.value)
// 如果当前保存的实例与DOM不一致或者DOM上已经有实例但我们没保存引用
if (chartInstance && chartInstance !== existingInstance) {
// 这种情况很少见,但为了保险,销毁旧的引用
if (!chartInstance.isDisposed()) {
chartInstance.dispose()
}
chartInstance = null
}
// 如果DOM变了transition导致的旧的chartInstance绑定的DOM已经不在了
// 这时 chartInstance.getDom() !== chartRef.value
if (chartInstance && chartInstance.getDom() !== chartRef.value) {
chartInstance.dispose()
chartInstance = null
}
// 如果DOM上已经有实例可能由其他途径创建复用它
if (!chartInstance && existingInstance) {
chartInstance = existingInstance
}
if (!chartInstance) {
chartInstance = echarts.init(chartRef.value)
}
// 补全当月所有日期
const now = new Date()
let daysInMonth
if (currentYear.value === now.getFullYear() && currentMonth.value === now.getMonth() + 1) {
// 如果是当前月,只显示到今天
daysInMonth = now.getDate()
} else {
// 如果是过去月,显示整月
daysInMonth = new Date(currentYear.value, currentMonth.value, 0).getDate()
}
const fullData = []
// 创建日期映射
const dataMap = new Map()
data.forEach((item) => {
const day = new Date(item.date).getDate()
dataMap.set(day, item)
}) })
for (let i = 1; i <= daysInMonth; i++) { if (response.success && response.data) {
const item = dataMap.get(i) balanceData.value = response.data
if (item) { // 如果不是首次加载,重新渲染余额图表
fullData.push(item) if (!firstLoading.value) {
} else { nextTick(() => {
fullData.push({ renderBalanceChart()
date: `${currentYear.value}-${String(currentMonth.value).padStart(2, '0')}-${String(i).padStart(2, '0')}`,
count: 0,
expense: 0,
income: 0,
balance: 0
}) })
} }
} }
} catch (error) {
const dates = fullData.map((item) => { console.error('获取余额统计数据失败:', error)
const date = new Date(item.date) showToast('获取余额统计数据失败')
return `${date.getDate()}`
})
// Calculate cumulative values
let accumulatedExpense = 0
let accumulatedIncome = 0
let accumulatedBalance = 0
const expenses = fullData.map((item) => {
accumulatedExpense += item.expense
return accumulatedExpense
})
const incomes = fullData.map((item) => {
accumulatedIncome += item.income
return accumulatedIncome
})
const balances = fullData.map((item) => {
accumulatedBalance += item.balance
return accumulatedBalance
})
const option = {
tooltip: {
trigger: 'axis',
formatter: function (params) {
let result = params[0].name + '<br/>'
params.forEach((param) => {
result += param.marker + param.seriesName + ': ' + formatMoney(param.value) + '<br/>'
})
return result
} }
},
legend: {
data: ['支出', '收入', '存款'],
bottom: 0,
textStyle: {
color: getCssVar('--chart-text-muted') // 适配深色模式
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '5%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLabel: {
color: getCssVar('--chart-text-muted') // 适配深色模式
}
},
yAxis: {
type: 'value',
splitNumber: 5,
axisLabel: {
color: getCssVar('--chart-text-muted'), // 适配深色模式
formatter: (value) => {
return value / 1000 + 'k'
}
},
splitLine: {
lineStyle: {
type: 'dashed',
color: getCssVar('--van-border-color') // 深色分割线
}
}
},
series: [
{
name: '支出',
type: 'line',
data: expenses,
itemStyle: { color: getCssVar('--chart-color-1') },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
},
{
name: '收入',
type: 'line',
data: incomes,
itemStyle: { color: getCssVar('--chart-color-2') },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
},
{
name: '存款',
type: 'line',
data: balances,
itemStyle: { color: getCssVar('--chart-color-13') },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
}
]
}
chartInstance.setOption(option)
} }
const renderPieChart = () => { const renderPieChart = () => {
@@ -830,7 +759,9 @@ const renderPieChart = () => {
}, },
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: '{b}: {c} ({d}%)' formatter: (params) => {
return `${params.name}: ¥${formatMoney(params.value)} (${params.percent.toFixed(1)}%)`
}
}, },
series: [ series: [
{ {
@@ -870,6 +801,263 @@ const renderPieChart = () => {
pieChartInstance.setOption(option) pieChartInstance.setOption(option)
} }
// 渲染余额变化图表(融合支出、收入、余额三条线)
const renderBalanceChart = () => {
if (!balanceChartRef.value) {
return
}
if (balanceData.value.length === 0 && dailyData.value.length === 0) {
return
}
// 尝试获取DOM上的现有实例
const existingInstance = echarts.getInstanceByDom(balanceChartRef.value)
if (balanceChartInstance && balanceChartInstance !== existingInstance) {
if (!balanceChartInstance.isDisposed()) {
balanceChartInstance.dispose()
}
balanceChartInstance = null
}
if (balanceChartInstance && balanceChartInstance.getDom() !== balanceChartRef.value) {
balanceChartInstance.dispose()
balanceChartInstance = null
}
if (!balanceChartInstance && existingInstance) {
balanceChartInstance = existingInstance
}
if (!balanceChartInstance) {
balanceChartInstance = echarts.init(balanceChartRef.value)
}
// 判断是年度统计还是月度统计
const isYearlyView = currentMonth.value === 0
let dates, expenses, incomes, balances
if (isYearlyView) {
// 按年统计:按月聚合数据
const monthlyMap = new Map()
const balanceMonthlyMap = new Map()
// 聚合 dailyData 按月
dailyData.value.forEach((item) => {
const date = new Date(item.date)
const month = date.getMonth() + 1 // 1-12
if (!monthlyMap.has(month)) {
monthlyMap.set(month, { expense: 0, income: 0 })
}
const data = monthlyMap.get(month)
data.expense += item.expense
data.income += item.income
})
// 聚合 balanceData 按月(取每月最后一天的余额)
balanceData.value.forEach((item) => {
const date = new Date(item.date)
const month = date.getMonth() + 1
const day = date.getDate()
if (!balanceMonthlyMap.has(month) || day > balanceMonthlyMap.get(month).day) {
balanceMonthlyMap.set(month, { balance: item.cumulativeBalance, day })
}
})
// 构建12个月的完整数据
const now = new Date()
const currentMonthNum = now.getFullYear() === currentYear.value ? now.getMonth() + 1 : 12
dates = []
const monthlyExpenses = []
const monthlyIncomes = []
const monthlyBalances = []
let accumulatedExpense = 0
let accumulatedIncome = 0
for (let m = 1; m <= currentMonthNum; m++) {
dates.push(`${m}`)
const data = monthlyMap.get(m) || { expense: 0, income: 0 }
accumulatedExpense += data.expense
accumulatedIncome += data.income
monthlyExpenses.push(accumulatedExpense)
monthlyIncomes.push(accumulatedIncome)
const balanceData = balanceMonthlyMap.get(m)
monthlyBalances.push(balanceData ? balanceData.balance : 0)
}
expenses = monthlyExpenses
incomes = monthlyIncomes
balances = monthlyBalances
} else {
// 按月统计:按日显示
const now = new Date()
let daysInMonth
if (currentYear.value === now.getFullYear() && currentMonth.value === now.getMonth() + 1) {
daysInMonth = now.getDate()
} else {
daysInMonth = new Date(currentYear.value, currentMonth.value, 0).getDate()
}
const fullData = []
const dataMap = new Map()
dailyData.value.forEach((item) => {
const day = new Date(item.date).getDate()
dataMap.set(day, item)
})
// 创建余额映射
const balanceMap = new Map()
if (balanceData.value && balanceData.value.length > 0) {
balanceData.value.forEach((item) => {
const day = new Date(item.date).getDate()
balanceMap.set(day, item.cumulativeBalance)
})
}
for (let i = 1; i <= daysInMonth; i++) {
const item = dataMap.get(i)
if (item) {
fullData.push(item)
} else {
fullData.push({
date: `${currentYear.value}-${String(currentMonth.value).padStart(2, '0')}-${String(i).padStart(2, '0')}`,
count: 0,
expense: 0,
income: 0,
balance: 0
})
}
}
dates = fullData.map((item) => {
const date = new Date(item.date)
return `${date.getDate()}`
})
// 计算累计支出和收入
let accumulatedExpense = 0
let accumulatedIncome = 0
expenses = fullData.map((item) => {
accumulatedExpense += item.expense
return accumulatedExpense
})
incomes = fullData.map((item) => {
accumulatedIncome += item.income
return accumulatedIncome
})
// 使用余额接口数据
balances = fullData.map((item, index) => {
const day = index + 1
return balanceMap.get(day) || 0
})
}
const legendData = [
{ name: '支出', value: '¥' + formatMoney(expenses[expenses.length - 1]) },
{ name: '收入', value: '¥' + formatMoney(incomes[incomes.length - 1]) },
{ name: '余额', value: '¥' + formatMoney(balances[balances.length - 1]) }
]
const option = {
tooltip: {
trigger: 'axis',
formatter: function (params) {
let result = params[0].name + '<br/>'
params.forEach((param) => {
result += param.marker + param.seriesName + ': ¥' + formatMoney(param.value) + '<br/>'
})
return result
}
},
legend: {
data: legendData.map((item) => item.name),
bottom: 0,
textStyle: {
color: getCssVar('--chart-text-muted')
},
formatter: function (name) {
const item = legendData.find((d) => d.name === name)
return item ? `${name} ${item.value}` : name
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '5%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLabel: {
color: getCssVar('--chart-text-muted'),
fontSize: 11
}
},
yAxis: {
type: 'value',
splitNumber: 4,
axisLabel: {
color: getCssVar('--chart-text-muted'),
fontSize: 11,
formatter: (value) => {
return value / 1000 + 'k'
}
},
splitLine: {
lineStyle: {
type: 'dashed',
color: getCssVar('--van-border-color')
}
}
},
series: [
{
name: '支出',
type: 'line',
data: expenses,
itemStyle: { color: '#ff6b6b' },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
},
{
name: '收入',
type: 'line',
data: incomes,
itemStyle: { color: '#51cf66' },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
},
{
name: '余额',
type: 'line',
data: balances,
itemStyle: { color: '#4c9cf1' },
showSymbol: false,
smooth: true,
lineStyle: { width: 2 }
}
]
}
balanceChartInstance.setOption(option)
}
// 跳转到智能分析页面 // 跳转到智能分析页面
const goToAnalysis = () => { const goToAnalysis = () => {
router.push('/bill-analysis') router.push('/bill-analysis')
@@ -907,7 +1095,7 @@ const loadCategoryBills = async (customIndex = null, customSize = null) => {
pageSize: customSize || billPageSize, pageSize: customSize || billPageSize,
type: selectedType.value, type: selectedType.value,
year: currentYear.value, year: currentYear.value,
month: currentMonth.value, month: currentMonth.value || 0,
sortByAmount: true sortByAmount: true
} }
@@ -1053,23 +1241,11 @@ onMounted(() => {
}) })
const handleResize = () => { const handleResize = () => {
chartInstance && chartInstance.resize()
pieChartInstance && pieChartInstance.resize() pieChartInstance && pieChartInstance.resize()
balanceChartInstance && balanceChartInstance.resize()
} }
// 监听DOM引用变化确保在月份切换DOM重建后重新渲染图表 // 监听DOM引用变化确保在月份切换DOM重建后重新渲染图表
watch(chartRef, (newVal) => {
// 无论有没有数据只要DOM变了就尝试渲染
// 如果没有数据renderChart 内部也应该处理(或者我们可以传空数据)
if (newVal) {
setTimeout(() => {
// 传入当前 dailyData即使是空的renderChart 应该能处理
renderChart(dailyData.value || [])
chartInstance && chartInstance.resize()
}, 50)
}
})
watch(pieChartRef, (newVal) => { watch(pieChartRef, (newVal) => {
if (newVal) { if (newVal) {
setTimeout(() => { setTimeout(() => {
@@ -1079,6 +1255,15 @@ watch(pieChartRef, (newVal) => {
} }
}) })
watch(balanceChartRef, (newVal) => {
if (newVal) {
setTimeout(() => {
renderBalanceChart()
balanceChartInstance && balanceChartInstance.resize()
}, 50)
}
})
// 页面激活时刷新数据(从其他页面返回时) // 页面激活时刷新数据(从其他页面返回时)
onActivated(() => { onActivated(() => {
fetchStatistics() fetchStatistics()
@@ -1097,8 +1282,8 @@ onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener &&
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted) window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
chartInstance && chartInstance.dispose()
pieChartInstance && pieChartInstance.dispose() pieChartInstance && pieChartInstance.dispose()
balanceChartInstance && balanceChartInstance.dispose()
}) })
const onGlobalTransactionsChanged = () => { const onGlobalTransactionsChanged = () => {
@@ -1112,6 +1297,20 @@ onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener &&
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged) window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
}) })
// 监听日期选择模式变化更新selectedDate数组
watch(dateSelectionMode, (newMode) => {
if (newMode === 'year') {
// 切换到年份模式:只保留年份
selectedDate.value = [currentYear.value.toString()]
} else {
// 切换到月份模式:添加当前月份
selectedDate.value = [
currentYear.value.toString(),
(currentMonth.value || new Date().getMonth() + 1).toString().padStart(2, '0')
]
}
})
</script> </script>
<style scoped> <style scoped>
@@ -1139,6 +1338,36 @@ onBeforeUnmount(() => {
color: var(--van-text-color); color: var(--van-text-color);
} }
/* 余额卡片 */
.balance-amount {
text-align: center;
padding: 9px 0 8px 0;
font-size: 32px;
font-weight: bold;
color: var(--van-text-color);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.balance-value {
color: var(--chart-color-13);
transition: color 0.3s ease;
}
.balance-value.balance-positive {
color: var(--van-success-color);
}
.balance-value.balance-negative {
color: var(--van-danger-color);
}
.balance-chart {
border-radius: 8px;
padding: 10px 0;
margin: 0 -12px;
}
/* 环形图 */ /* 环形图 */
.chart-container { .chart-container {
padding: 0; padding: 0;
@@ -1405,8 +1634,8 @@ onBeforeUnmount(() => {
padding: 12px; padding: 12px;
} }
.side-by-side-cards .card-header { .card-header {
margin-bottom: 12px; margin-bottom: 0;
} }
.text-ellipsis { .text-ellipsis {
@@ -1416,4 +1645,25 @@ onBeforeUnmount(() => {
font-size: 13px; font-size: 13px;
} }
/* 日期选择器头部 */
.date-picker-header {
padding: 12px 16px 0;
background: var(--van-background-2);
border-bottom: 1px solid var(--van-border-color);
}
.date-picker-header :deep(.van-tabs) {
background: transparent;
}
.date-picker-header :deep(.van-tabs__nav) {
background: transparent;
padding-bottom: 0;
}
.date-picker-header :deep(.van-tab) {
font-size: 15px;
font-weight: 500;
}
</style> </style>

View File

@@ -12,7 +12,10 @@
</div> </div>
<!-- 下拉刷新区域 --> <!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh"> <van-pull-refresh
v-model="refreshing"
@refresh="onRefresh"
>
<!-- 加载提示 --> <!-- 加载提示 -->
<van-loading <van-loading
v-if="loading && !(transactionList && transactionList.length)" v-if="loading && !(transactionList && transactionList.length)"

View File

@@ -1,4 +1,4 @@
<template> <template>
<div class="page-container-flex unconfirmed-classification"> <div class="page-container-flex unconfirmed-classification">
<van-nav-bar <van-nav-bar
title="待确认分类" title="待确认分类"
@@ -29,17 +29,63 @@
</van-loading> </van-loading>
</div> </div>
<TransactionList <div
v-else-if="treeData.length === 0 && !loading"
class="empty-container"
>
<van-empty description="暂无待确认分类" />
</div>
<van-collapse
v-else v-else
:transactions="displayTransactions" v-model="activeNames"
:loading="loading" :border="false"
:finished="true" >
show-checkbox <van-collapse-item
v-for="typeNode in treeData"
:key="typeNode.id"
:name="typeNode.id"
class="type-node"
>
<template #title>
<div class="node-title">
<span class="node-count">{{ typeNode.count }}</span>
<span class="node-name">{{ typeNode.text }}</span>
<span class="node-amount">{{ formatAmount(typeNode.amount) }}</span>
</div>
</template>
<van-collapse
v-model="activeClassifyNames"
:border="false"
class="classify-collapse"
>
<van-collapse-item
v-for="classifyNode in typeNode.children"
:key="classifyNode.id"
:name="classifyNode.id"
class="classify-node"
>
<template #title>
<div class="node-title">
<span class="node-count">{{ classifyNode.count }}</span>
<span class="node-name">{{ classifyNode.text }}</span>
<span class="node-amount">{{ formatAmount(classifyNode.amount) }}</span>
</div>
</template>
<TransactionList
:transactions="classifyNode.children.map(c => c.transaction)"
:show-delete="false"
:show-checkbox="true"
:selected-ids="selectedIds" :selected-ids="selectedIds"
@click="handleTransactionClick" @click="handleTransactionClick"
@delete="handleTransactionDeleted" @update:selected-ids="handleUpdateSelectedIds"
@update:selected-ids="updateSelectedIds"
/> />
</van-collapse-item>
</van-collapse>
</van-collapse-item>
</van-collapse>
</div> </div>
<!-- 交易详情弹窗 --> <!-- 交易详情弹窗 -->
@@ -56,8 +102,8 @@ import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast, showConfirmDialog } from 'vant' import { showToast, showConfirmDialog } from 'vant'
import { getUnconfirmedTransactionList, confirmAllUnconfirmed } from '@/api/transactionRecord' import { getUnconfirmedTransactionList, confirmAllUnconfirmed } from '@/api/transactionRecord'
import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue' import TransactionDetail from '@/components/TransactionDetail.vue'
import TransactionList from '@/components/TransactionList.vue'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
@@ -66,16 +112,28 @@ const transactions = ref([])
const showDetail = ref(false) const showDetail = ref(false)
const currentTransaction = ref(null) const currentTransaction = ref(null)
const selectedIds = ref(new Set()) const selectedIds = ref(new Set())
const activeNames = ref([])
const activeClassifyNames = ref([])
const TYPE_NAMES = {
0: '支出',
1: '收入',
2: '不记收支'
}
const onClickLeft = () => { const onClickLeft = () => {
if (window.history.length > 1) {
router.back() router.back()
} else {
router.replace('/')
}
} }
const handleConfirmSelected = async () => { const handleConfirmSelected = async () => {
try { try {
await showConfirmDialog({ await showConfirmDialog({
title: '提示', title: '提示',
message: `确定要将这 ${transactions.value.length} 条记录的所有建议分类转为正式分类吗?` message: `确定要将这 ${selectedIds.value.size} 条记录的所有建议分类转为正式分类吗?`
}) })
confirming.value = true confirming.value = true
@@ -95,13 +153,76 @@ const handleConfirmSelected = async () => {
} }
} }
// 转换数据格式以适配 TransactionList 组件 const formatAmount = (amount) => {
const displayTransactions = computed(() => { if (amount === null || amount === undefined) {return ''}
return transactions.value.map((t) => ({ const num = parseFloat(amount)
...t, if (isNaN(num)) {return ''}
upsetedClassify: t.unconfirmedClassify, return num.toFixed(2)
upsetedType: t.unconfirmedType }
}))
const buildTreeData = (data) => {
const typeMap = {}
data.forEach((item) => {
const type = item.unconfirmedType ?? item.type
const classify = item.unconfirmedClassify ?? item.classify
const typeName = TYPE_NAMES[type] || '未分类'
const classifyName = classify || '未分类'
if (!typeMap[typeName]) {
typeMap[typeName] = {
id: `type-${type}`,
text: typeName,
type: 'type',
typeId: type,
children: [],
count: 0,
amount: 0
}
}
let classifyNode = typeMap[typeName].children.find((c) => c.text === classifyName)
if (!classifyNode) {
classifyNode = {
id: `classify-${type}-${classifyName}`,
text: classifyName,
type: 'classify',
typeId: type,
classify: classifyName,
children: [],
count: 0,
amount: 0
}
typeMap[typeName].children.push(classifyNode)
}
classifyNode.children.push({
id: item.id,
text: item.reason || item.occurredAt,
type: 'transaction',
transaction: item
})
classifyNode.count += 1
classifyNode.amount += parseFloat(item.amount) || 0
typeMap[typeName].count += 1
typeMap[typeName].amount += parseFloat(item.amount) || 0
})
return Object.values(typeMap).map((typeNode) => {
typeNode.children.forEach((classifyNode) => {
classifyNode.children.sort((a, b) => {
const dateA = new Date(a.transaction.occurredAt)
const dateB = new Date(b.transaction.occurredAt)
return dateB - dateA
})
})
return typeNode
})
}
const treeData = computed(() => {
return buildTreeData(transactions.value)
}) })
const loadData = async () => { const loadData = async () => {
@@ -109,12 +230,9 @@ const loadData = async () => {
try { try {
const response = await getUnconfirmedTransactionList() const response = await getUnconfirmedTransactionList()
if (response && response.success) { if (response && response.success) {
transactions.value = (response.data || []).map((t) => ({ transactions.value = response.data || []
...t,
upsetedClassify: t.unconfirmedClassify,
upsetedType: t.unconfirmedType
}))
selectedIds.value = new Set(response.data.map((t) => t.id)) selectedIds.value = new Set(response.data.map((t) => t.id))
activeNames.value = treeData.value.map((node) => node.id)
} }
} catch (error) { } catch (error) {
console.error('获取待确认列表失败:', error) console.error('获取待确认列表失败:', error)
@@ -128,12 +246,8 @@ const handleTransactionClick = (transaction) => {
showDetail.value = true showDetail.value = true
} }
const handleTransactionDeleted = (id) => { const handleUpdateSelectedIds = (newSelectedIds) => {
transactions.value = transactions.value.filter((t) => t.id !== id) selectedIds.value = newSelectedIds
}
const updateSelectedIds = (ids) => {
selectedIds.value = new Set(ids)
} }
const handleDetailSave = () => { const handleDetailSave = () => {
@@ -155,17 +269,66 @@ onMounted(() => {
.scroll-content { .scroll-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px;
} }
.loading-container { .loading-container,
.empty-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 200px; height: 200px;
} }
/* 设置页面容器背景色 */ .node-title {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.node-name {
flex: 1;
font-weight: 500;
}
.node-count {
font-size: 12px;
color: #fff;
background: var(--van-primary-color);
padding: 2px 6px;
border-radius: 4px;
}
.node-amount {
font-size: 14px;
font-weight: 600;
color: var(--van-orange);
}
.type-node :deep(.van-collapse-item__title) {
font-size: 16px;
font-weight: 600;
}
.classify-node :deep(.van-collapse-item__title) {
font-size: 14px;
}
.classify-collapse {
padding-left: 8px;
}
.classify-collapse :deep(.van-cell-group--inset) {
margin-left: -24px;
width: calc(100vw - 48px)
}
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: transparent !important; background: transparent !important;
} }
:deep(.van-cell) {
padding: 8px 12px;
}
</style> </style>

View File

@@ -0,0 +1,18 @@
namespace WebApi.Test.Basic;
public class BaseTest
{
public BaseTest()
{
// 初始化雪花算法ID生成器
var options = new IdGeneratorOptions(1);
try
{
YitIdHelper.SetIdGenerator(options);
}
catch (Exception)
{
// 忽略重复设置可能引发的异常
}
}
}

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