Compare commits
16 Commits
42171c01db
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 642ea2d3ef | |||
|
|
a5f1a370c7 | ||
|
|
b7399d185f | ||
|
|
5537e1234d | ||
|
|
f6437b2ada | ||
|
|
cc0623c15a | ||
|
|
cb4273fad0 | ||
|
|
99ec5ea0a4 | ||
|
|
89e1e74b76 | ||
|
|
ed0a44851d | ||
|
|
a1eebd0b3f | ||
|
|
ef34a1bb5d | ||
|
|
ab9aab7beb | ||
|
|
61d26fc971 | ||
|
|
f537b53ebd | ||
|
|
b654265d96 |
75
.gitea/workflows/deploy.yaml
Normal file
75
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
# BillAI 自动部署工作流
|
||||
# 当 master 分支有 push 时自动触发部署
|
||||
# 模式: Docker 模式 - Job 在 docker:latest 容器中执行
|
||||
|
||||
name: Deploy BillAI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Production
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
image: docker:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${{ vars.DEPLOY_PATH }}:${{ vars.DEPLOY_PATH }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
echo "=== 拉取最新代码 ==="
|
||||
echo "部署路径: ${{ vars.DEPLOY_PATH }}"
|
||||
git config --global --add safe.directory ${{ vars.DEPLOY_PATH }}
|
||||
cd ${{ vars.DEPLOY_PATH }}
|
||||
git fetch origin master
|
||||
# git reset --hard origin/master
|
||||
echo "当前版本: $(git log -1 --format='%h %s')"
|
||||
|
||||
- name: Build and deploy
|
||||
run: |
|
||||
echo "=== 构建并部署服务 ==="
|
||||
cd ${{ vars.DEPLOY_PATH }}
|
||||
docker compose up -d --build --remove-orphans
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
echo "=== 清理旧镜像 ==="
|
||||
docker image prune -f
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
echo "=== 健康检查 ==="
|
||||
echo "等待服务启动..."
|
||||
sleep 15
|
||||
|
||||
# 通过 Docker 健康检查状态判断(不依赖端口暴露)
|
||||
check_container() {
|
||||
local name=$1
|
||||
local container=$2
|
||||
local status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null)
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo "✓ $name 服务正常"
|
||||
return 0
|
||||
else
|
||||
echo "✗ $name 服务异常 (状态: $status)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
FAILED=0
|
||||
check_container "Web" "billai-web" || FAILED=1
|
||||
check_container "Server" "billai-server" || FAILED=1
|
||||
check_container "Analyzer" "billai-analyzer" || FAILED=1
|
||||
check_container "MongoDB" "billai-mongodb" || FAILED=1
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo "=== 部署成功 ==="
|
||||
else
|
||||
echo "=== 部署失败:部分服务异常 ==="
|
||||
docker compose ps
|
||||
exit 1
|
||||
fi
|
||||
184
AGENTS.md
184
AGENTS.md
@@ -3,139 +3,125 @@
|
||||
Guidelines for AI coding agents working on BillAI - a microservices bill analysis system.
|
||||
|
||||
## Architecture
|
||||
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript
|
||||
- `server/` - Go 1.21 + Gin + MongoDB
|
||||
- `analyzer/` - Python 3.12 + FastAPI
|
||||
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend Proxy & UI)
|
||||
- `server/` - Go 1.21 + Gin + MongoDB (Main API & Data Storage)
|
||||
- `analyzer/` - Python 3.12 + FastAPI (Data Cleaning & Analysis Service)
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
### Frontend (web/)
|
||||
**Working Directory:** `/Users/clz/Projects/BillAI/web`
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server
|
||||
npm run build # Production build
|
||||
npm run check # TypeScript check
|
||||
npm run check # TypeScript check (svelte-check)
|
||||
npm run lint # Prettier + ESLint
|
||||
npm run format # Format code
|
||||
npm run test # Run all tests
|
||||
npx vitest run src/routes/+page.spec.ts # Single test file
|
||||
npx vitest run -t "test name" # Test by name
|
||||
npm run format # Format code (Prettier)
|
||||
npm run test:unit # Run all unit tests (Vitest)
|
||||
npx vitest run src/routes/+page.spec.ts # Run single test file
|
||||
npx vitest run -t "test name" # Run test by name pattern
|
||||
```
|
||||
|
||||
### Backend (server/)
|
||||
**Working Directory:** `/Users/clz/Projects/BillAI/server`
|
||||
|
||||
```bash
|
||||
go run . # Start server
|
||||
go build . # Build binary
|
||||
go build -o server . # Build binary
|
||||
go mod tidy # Clean dependencies
|
||||
go test ./... # All tests
|
||||
go test ./handler/... # Package tests
|
||||
go test -run TestName # Single test
|
||||
go test ./... # Run all tests
|
||||
go test ./handler/... # Run package tests
|
||||
go test -run TestName # Run single test function
|
||||
go test -v ./handler/... # Run tests with verbose output
|
||||
```
|
||||
|
||||
### Analyzer (analyzer/)
|
||||
**Working Directory:** `/Users/clz/Projects/BillAI/analyzer`
|
||||
|
||||
```bash
|
||||
python server.py # Start FastAPI server
|
||||
pytest # All tests
|
||||
pytest test_file.py # Single file
|
||||
pytest -k "test_name" # Test by pattern
|
||||
python server.py # Start FastAPI server directly
|
||||
uvicorn server:app --reload # Start with hot reload
|
||||
pytest # Run all tests
|
||||
pytest test_file.py # Run single test file
|
||||
pytest -k "test_name" # Run test by name pattern
|
||||
pip install -r requirements.txt # Install dependencies
|
||||
```
|
||||
|
||||
### Docker
|
||||
**Working Directory:** `/Users/clz/Projects/BillAI`
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build # Start/rebuild all
|
||||
docker-compose up -d --build # Start/rebuild all services
|
||||
docker-compose logs -f server # Follow service logs
|
||||
docker-compose down # Stop all services
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### TypeScript/Svelte
|
||||
**Prettier config:** Tabs, single quotes, no trailing commas, width 100
|
||||
### General
|
||||
- **Comments:** Existing comments often use Chinese for business logic explanations. Maintain this style where appropriate, but English is also acceptable for technical explanations.
|
||||
- **Conventions:** Follow existing patterns strictly. Do not introduce new frameworks or libraries without checking `package.json`/`go.mod`/`requirements.txt`.
|
||||
|
||||
**Imports:**
|
||||
```typescript
|
||||
import { browser } from '$app/environment'; // SvelteKit
|
||||
import { auth } from '$lib/stores/auth'; // Internal
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
```
|
||||
### TypeScript/Svelte (web/)
|
||||
- **Formatting:** Prettier (Tabs, single quotes, no trailing commas, printWidth 100).
|
||||
- **Naming:** `PascalCase` for types/components/interfaces, `camelCase` for variables/functions.
|
||||
- **Imports:** Use `$lib` alias for internal imports.
|
||||
```typescript
|
||||
import { browser } from '$app/environment';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import type { UIBill } from '$lib/models/bill';
|
||||
```
|
||||
- **Types:** Define interfaces for data models. Use `export interface`.
|
||||
- **Error Handling:** Check `response.ok`. Throw `Error` with status for UI to catch.
|
||||
|
||||
**Types:**
|
||||
```typescript
|
||||
export interface UploadResponse {
|
||||
result: boolean;
|
||||
message: string;
|
||||
data?: UploadData;
|
||||
}
|
||||
```
|
||||
### Go Backend (server/)
|
||||
- **Structure:** `handler` (HTTP) → `service` (Logic) → `repository` (DB) → `model` (Structs).
|
||||
- **Tags:** Use `json` (snake_case) and `form` tags. Use `omitempty` for optional fields.
|
||||
```go
|
||||
type UpdateBillRequest struct {
|
||||
Category *string `json:"category,omitempty" form:"category"`
|
||||
}
|
||||
```
|
||||
- **Error Handling:** Return `500` for DB errors, `400` for bad requests. Wrap errors with context.
|
||||
```go
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, Response{Result: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Naming:** PascalCase (types, components), camelCase (functions, variables)
|
||||
|
||||
**Error handling:**
|
||||
```typescript
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
// Handle 401 -> logout redirect
|
||||
```
|
||||
|
||||
### Go Backend
|
||||
**Structure:** `handler/` → `service/` → `repository/` → MongoDB
|
||||
|
||||
**JSON tags:** snake_case, omitempty for optional fields
|
||||
```go
|
||||
type UpdateBillRequest struct {
|
||||
Category *string `json:"category,omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Response format:**
|
||||
```go
|
||||
type Response struct {
|
||||
Result bool `json:"result"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Error handling:**
|
||||
```go
|
||||
if err == repository.ErrNotFound {
|
||||
c.JSON(http.StatusNotFound, Response{Result: false, Message: "not found"})
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### Python Analyzer
|
||||
**Style:** PEP 8, type hints, Pydantic models
|
||||
|
||||
```python
|
||||
def do_clean(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
bill_type: str = "auto"
|
||||
) -> tuple[bool, str, str]:
|
||||
```
|
||||
|
||||
**Error handling:**
|
||||
```python
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
```
|
||||
### Python Analyzer (analyzer/)
|
||||
- **Style:** PEP 8. Use `snake_case` for variables/functions.
|
||||
- **Type Hints:** Mandatory for function arguments and return types.
|
||||
- **Models:** Use `pydantic.BaseModel` for API schemas.
|
||||
```python
|
||||
class CleanRequest(BaseModel):
|
||||
input_path: str
|
||||
bill_type: Optional[str] = "auto"
|
||||
```
|
||||
- **Docstrings:** Use triple quotes. Chinese descriptions are common for API docs.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
**API Flow:** Frontend (SvelteKit proxy) → Go API → MongoDB + Python analyzer
|
||||
- **API Flow:**
|
||||
- Frontend talks to `server` (Go) via `/api` proxy.
|
||||
- `server` handles auth, DB operations, and delegates complex file processing to `analyzer` (Python).
|
||||
- `analyzer` cleanses CSV/Excel files and returns structured JSON/CSV to `server`.
|
||||
|
||||
**Auth:** JWT tokens, Bearer header, 401 → logout redirect
|
||||
- **Authentication:**
|
||||
- JWT based. Token stored in frontend.
|
||||
- Header: `Authorization: Bearer <token>`.
|
||||
- Backend middleware checks token. 401 triggers logout/redirect.
|
||||
|
||||
**File Processing:** ZIP → extract → convert (GBK→UTF-8, xlsx→csv) → clean → import
|
||||
|
||||
**Testing:** Vitest + Playwright for frontend, Go test for backend
|
||||
- **File Processing:**
|
||||
- Flow: Upload (ZIP/XLSX) -> Extract/Convert (to UTF-8 CSV) -> Clean (normalize columns) -> Import to DB.
|
||||
- `analyzer` uses `openpyxl` for Excel and regex for cleaning text.
|
||||
|
||||
## Important Files
|
||||
- `web/src/lib/api.ts` - API client
|
||||
- `web/src/lib/models/` - UI data models
|
||||
- `server/handler/` - HTTP handlers
|
||||
- `server/service/` - Business logic
|
||||
- `server/model/` - Go data structures
|
||||
- `analyzer/cleaners/` - Bill processing
|
||||
- `mock_data/*.zip` - Test data (password: 123456)
|
||||
- `web/src/lib/api.ts` - Centralized API client methods.
|
||||
- `web/src/lib/models/*.ts` - Frontend data models (should match backend JSON).
|
||||
- `server/handler/*.go` - HTTP endpoint definitions.
|
||||
- `server/repository/mongo.go` - MongoDB connection and queries.
|
||||
- `analyzer/server.py` - FastAPI entry point and routing.
|
||||
- `analyzer/cleaners/*.py` - Specific logic for Alipay/Wechat/JD bills.
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -5,6 +5,49 @@
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [1.3.1] - 2026-01-26
|
||||
|
||||
### 优化
|
||||
- **版本号显示优化** - 侧边栏版本号按钮样式改进
|
||||
- 移至次级导航区域,与其他菜单项样式一致
|
||||
- 更新日志改用 Sheet 组件(右侧滑出),替代底部 Drawer
|
||||
- 统一暗色主题下的视觉效果
|
||||
|
||||
## [1.3.0] - 2026-01-26
|
||||
|
||||
### 新增
|
||||
- **京东账单支持** - 支持京东白条账单上传和清洗
|
||||
- 自动识别京东账单类型(交易流水 ZIP)
|
||||
- 解析京东白条账单 CSV 格式(含还款日期信息)
|
||||
- 京东专属分类映射配置(`config/category_jd.yaml`)
|
||||
- 支持京东外卖、京东平台商户等商户识别
|
||||
- 上传页面和账单列表页面添加"京东"选项
|
||||
- 账单来源 Badge 添加紫色京东标识
|
||||
|
||||
### 优化
|
||||
- **京东订单智能去重** - 上传京东账单时自动软删除其他来源中的京东订单
|
||||
- 识别描述中包含"京东-订单编号"的支付宝/微信账单
|
||||
- 软删除冲突记录,避免重复计入支出
|
||||
- 上传响应返回被删除的记录数
|
||||
- **分类推断复核等级优化** - 京东账单引入 LOW 复核等级
|
||||
- 商户映射成功(如"京东外卖"):无需复核
|
||||
- 原分类映射成功(如"食品酒饮"→餐饮美食):无需复核
|
||||
- 通用关键词匹配成功:**LOW 复核**(需确认推断准确性)
|
||||
- 未知分类或匹配失败:HIGH 复核
|
||||
- **京东平台商户关键词扩展** - 在通用分类配置中添加京东平台常见关键词
|
||||
- 宠物用品:小佩、米家宠物、猫砂、猫粮等
|
||||
- 数码电器:小米、延长保修、家电等
|
||||
|
||||
### 技术改进
|
||||
- 新增 `analyzer/cleaners/jd.py` 京东账单清理器
|
||||
- 新增 `analyzer/config/category_jd.yaml` 京东专属配置
|
||||
- 后端新增 `SoftDeleteJDRelatedBills()` 接口和实现
|
||||
- 前端 API 类型添加 `'jd'` 支持
|
||||
- 新增单元测试 `analyzer/test_jd_cleaner.py`(11 个测试用例)
|
||||
|
||||
### 文档
|
||||
- 更新 `TODO.md` 添加 Gitea Webhook 自动部署计划
|
||||
|
||||
## [1.2.0] - 2026-01-25
|
||||
|
||||
### 新增
|
||||
|
||||
20
TODO.md
20
TODO.md
@@ -34,6 +34,26 @@
|
||||
|
||||
### 高优先级
|
||||
|
||||
- [ ] **Gitea Webhook 自动部署**
|
||||
- Webhook 服务(Go 实现)
|
||||
- 监听端口 9000,接收 Gitea POST 请求
|
||||
- HMAC-SHA256 签名验证
|
||||
- 仅处理 master/main 分支的 push 事件
|
||||
- 执行部署脚本
|
||||
- 部署脚本 (deploy.sh)
|
||||
- `git pull origin master`
|
||||
- `docker-compose up -d --build --remove-orphans`
|
||||
- 自动清理旧镜像
|
||||
- 健康检查验证
|
||||
- docker-compose 配置
|
||||
- webhook 服务定义
|
||||
- 挂载 docker.sock 和项目目录
|
||||
- 环境变量配置(WEBHOOK_SECRET)
|
||||
- Gitea 仓库配置
|
||||
- 添加 Webhook URL: `http://服务器IP:9000/webhook`
|
||||
- 设置 Secret(与服务端一致)
|
||||
- 选择 Push 事件,分支过滤 `refs/heads/master`
|
||||
|
||||
- [ ] **SSE 实时状态推送**
|
||||
- 服务器实现 `/events` SSE 端点
|
||||
- 前端使用 EventSource 接收状态
|
||||
|
||||
13
analyzer/.dockerignore
Normal file
13
analyzer/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
40
analyzer/analyze_jd_bills.py
Normal file
40
analyzer/analyze_jd_bills.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""分析京东账单数据"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
with open('../jd_bills.json', 'r', encoding='utf-8') as f:
|
||||
d = json.load(f)
|
||||
|
||||
bills = [b for b in d['data']['bills'] if b['bill_type'] == 'jd']
|
||||
print(f'Total JD bills: {len(bills)}')
|
||||
print()
|
||||
|
||||
# Review level distribution
|
||||
review_levels = {}
|
||||
for b in bills:
|
||||
lvl = b['review_level'] or 'NONE'
|
||||
review_levels[lvl] = review_levels.get(lvl, 0) + 1
|
||||
print('Review level distribution:')
|
||||
for lvl, cnt in sorted(review_levels.items()):
|
||||
print(f' {lvl}: {cnt}')
|
||||
print()
|
||||
|
||||
# Category distribution
|
||||
categories = {}
|
||||
for b in bills:
|
||||
cat = b['category']
|
||||
categories[cat] = categories.get(cat, 0) + 1
|
||||
print('Category distribution:')
|
||||
for cat, cnt in sorted(categories.items(), key=lambda x: -x[1]):
|
||||
print(f' {cat}: {cnt}')
|
||||
print()
|
||||
|
||||
# Show bills that need review
|
||||
print('Bills needing review:')
|
||||
print(f"{'Level':<5} | {'Category':<12} | {'Merchant':<20} | Description")
|
||||
print('-' * 70)
|
||||
for b in bills:
|
||||
if b['review_level']:
|
||||
print(f"{b['review_level']:<5} | {b['category']:<12} | {b['merchant'][:20]:<20} | {b['description'][:30]}")
|
||||
116
analyzer/test_jd_cleaner.py
Normal file
116
analyzer/test_jd_cleaner.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
测试京东账单清洗器
|
||||
"""
|
||||
import zipfile
|
||||
import tempfile
|
||||
import os
|
||||
import csv
|
||||
import sys
|
||||
|
||||
# 确保输出使用 UTF-8
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
def test_jd_cleaner():
|
||||
zip_path = r'D:\Projects\BillAI\mock_data\京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip'
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zf.extractall(tmpdir, pwd=b'683263')
|
||||
|
||||
# Find CSV file
|
||||
for f in os.listdir(tmpdir):
|
||||
if f.endswith('.csv'):
|
||||
input_file = os.path.join(tmpdir, f)
|
||||
output_file = os.path.join(tmpdir, 'output.csv')
|
||||
|
||||
print(f"Input file: {f}")
|
||||
print("-" * 60)
|
||||
|
||||
# Run cleaner
|
||||
from cleaners.jd import JDCleaner
|
||||
cleaner = JDCleaner(input_file, output_file)
|
||||
cleaner.clean()
|
||||
|
||||
# Read output and show review levels
|
||||
print("\n" + "=" * 60)
|
||||
print("OUTPUT REVIEW LEVELS")
|
||||
print("=" * 60)
|
||||
|
||||
with open(output_file, 'r', encoding='utf-8') as of:
|
||||
reader = csv.reader(of)
|
||||
header = next(reader)
|
||||
review_idx = header.index('复核等级') if '复核等级' in header else -1
|
||||
cat_idx = header.index('交易分类') if '交易分类' in header else -1
|
||||
merchant_idx = header.index('交易对方') if '交易对方' in header else -1
|
||||
desc_idx = header.index('商品说明') if '商品说明' in header else -1
|
||||
|
||||
stats = {'': 0, 'LOW': 0, 'HIGH': 0}
|
||||
rows_needing_review = []
|
||||
|
||||
for row in reader:
|
||||
review = row[review_idx] if review_idx >= 0 else ''
|
||||
stats[review] = stats.get(review, 0) + 1
|
||||
if review: # Collect rows that need review
|
||||
cat = row[cat_idx] if cat_idx >= 0 else ''
|
||||
merchant = row[merchant_idx][:20] if merchant_idx >= 0 else ''
|
||||
desc = row[desc_idx][:25] if desc_idx >= 0 else ''
|
||||
rows_needing_review.append((review, cat, merchant, desc))
|
||||
|
||||
# Print rows needing review
|
||||
print(f"{'Level':<5} | {'Category':<12} | {'Merchant':<20} | Description")
|
||||
print("-" * 70)
|
||||
for review, cat, merchant, desc in rows_needing_review:
|
||||
print(f"{review:<5} | {cat:<12} | {merchant:<20} | {desc}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("STATISTICS")
|
||||
print("=" * 60)
|
||||
print(f"No review (confident): {stats['']}")
|
||||
print(f"LOW (keyword match): {stats['LOW']}")
|
||||
print(f"HIGH (needs manual): {stats['HIGH']}")
|
||||
print(f"Total: {sum(stats.values())}")
|
||||
|
||||
|
||||
def test_infer_jd_category():
|
||||
"""测试分类推断逻辑"""
|
||||
from cleaners.jd import infer_jd_category
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("INFER_JD_CATEGORY TESTS")
|
||||
print("=" * 60)
|
||||
|
||||
tests = [
|
||||
# (商户, 商品, 原分类, 预期等级, 说明)
|
||||
('京东外卖', '火鸡面', '', 0, '商户映射'),
|
||||
('京东平台商户', 'xxx', '食品酒饮', 0, '原分类映射'),
|
||||
('京东平台商户', 'xxx', '数码电器', 0, '原分类映射'),
|
||||
('京东平台商户', 'xxx', '日用百货', 0, '原分类映射'),
|
||||
('京东平台商户', 'xxx', '图书文娱', 0, '原分类映射'),
|
||||
('京东平台商户', '猫粮', '其他', 1, '空映射+关键词成功'),
|
||||
('京东平台商户', '咖啡', '其他网购', 1, '空映射+关键词成功'),
|
||||
('京东平台商户', 'xxx', '其他', 2, '空映射+关键词失败'),
|
||||
('京东平台商户', 'xxx', '家居用品', 2, '未知分类'),
|
||||
('京东平台商户', 'xxx', '母婴', 2, '未知分类'),
|
||||
('京东平台商户', 'xxx', '', 2, '无原分类+关键词失败'),
|
||||
]
|
||||
|
||||
level_map = {0: 'NONE', 1: 'LOW', 2: 'HIGH'}
|
||||
|
||||
print(f"{'Merchant':<15} | {'Product':<8} | {'OrigCat':<10} | {'Result':<12} | {'Level':<5} | {'Expected':<5} | Note")
|
||||
print("-" * 90)
|
||||
|
||||
all_pass = True
|
||||
for merchant, product, orig_cat, expected_level, note in tests:
|
||||
cat, certain, level = infer_jd_category(merchant, product, orig_cat)
|
||||
status = "✓" if level == expected_level else "✗"
|
||||
if level != expected_level:
|
||||
all_pass = False
|
||||
print(f"{merchant:<15} | {product:<8} | {orig_cat or '(empty)':<10} | {cat:<12} | {level_map[level]:<5} | {level_map[expected_level]:<5} | {note} {status}")
|
||||
|
||||
print("\n" + ("All tests passed!" if all_pass else "Some tests FAILED!"))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_infer_jd_category()
|
||||
print("\n")
|
||||
test_jd_cleaner()
|
||||
62
deploy.sh
Normal file
62
deploy.sh
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# BillAI 部署脚本
|
||||
# 用于手动部署或 Gitea Actions 自动部署
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}=== BillAI 部署开始 ===${NC}"
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo -e "\n${YELLOW}[1/4] 拉取最新代码${NC}"
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
echo "当前版本: $(git log -1 --format='%h %s')"
|
||||
|
||||
echo -e "\n${YELLOW}[2/4] 构建并部署服务${NC}"
|
||||
docker compose up -d --build --remove-orphans
|
||||
|
||||
echo -e "\n${YELLOW}[3/4] 清理旧镜像${NC}"
|
||||
docker image prune -f
|
||||
|
||||
echo -e "\n${YELLOW}[4/4] 健康检查${NC}"
|
||||
echo "等待服务启动..."
|
||||
sleep 15
|
||||
|
||||
# 检查服务状态(通过 Docker 健康检查状态)
|
||||
check_service() {
|
||||
local name=$1
|
||||
local container=$2
|
||||
local status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null)
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo -e " ${GREEN}✓${NC} $name 服务正常"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $name 服务异常 (状态: $status)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
FAILED=0
|
||||
check_service "Web" "billai-web" || FAILED=1
|
||||
check_service "Server" "billai-server" || FAILED=1
|
||||
check_service "Analyzer" "billai-analyzer" || FAILED=1
|
||||
check_service "MongoDB" "billai-mongodb" || FAILED=1
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "\n${GREEN}=== 部署成功 ===${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "\n${RED}=== 部署失败:部分服务异常 ===${NC}"
|
||||
docker compose ps
|
||||
exit 1
|
||||
fi
|
||||
42
docker-compose.runner.yaml
Normal file
42
docker-compose.runner.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Gitea Actions Runner - 自动部署
|
||||
#
|
||||
# 使用方法:
|
||||
# 1. 在 Gitea 仓库获取 Runner Token
|
||||
# 访问:https://git.fadinglight.cn/clz/billai/settings/actions/runners
|
||||
# 点击 "Create new Runner" 复制 Token
|
||||
#
|
||||
# 2. 创建 .env 文件或设置环境变量
|
||||
# echo "GITEA_RUNNER_REGISTRATION_TOKEN=你的Token" > runner/.env
|
||||
#
|
||||
# 3. 启动 Runner
|
||||
# docker compose -f docker-compose.runner.yaml up -d
|
||||
#
|
||||
# 4. 在 Gitea 仓库添加变量
|
||||
# 访问:https://git.fadinglight.cn/clz/billai/settings/actions/variables
|
||||
# 添加 DEPLOY_PATH = /workspace/billai
|
||||
#
|
||||
# 模式说明:
|
||||
# 使用 Docker 模式,每个 Job 会在 docker:latest 容器中执行
|
||||
# 容器自带 docker CLI,通过挂载 docker.sock 控制宿主机的 Docker
|
||||
|
||||
services:
|
||||
runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: billai-runner
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./runner/.env
|
||||
environment:
|
||||
GITEA_INSTANCE_URL: "https://git.fadinglight.cn"
|
||||
GITEA_RUNNER_NAME: "billai-runner"
|
||||
GITEA_RUNNER_LABELS: "self-hosted:docker://docker:latest,ubuntu-latest:docker://docker:latest"
|
||||
CONFIG_FILE: /config.yaml
|
||||
volumes:
|
||||
# Runner 配置文件
|
||||
- ./runner/config.yaml:/config.yaml
|
||||
# Runner 数据持久化
|
||||
- ./runner/data:/data
|
||||
# Docker socket - Runner 通过它创建 Job 容器
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 项目目录 - 挂载到 Runner 和 Job 容器都能访问的路径
|
||||
- .:/workspace/billai
|
||||
BIN
mock_data/京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip
Normal file
BIN
mock_data/京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip
Normal file
Binary file not shown.
3
runner/.env.example
Normal file
3
runner/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# Gitea Runner 配置
|
||||
# 从 Gitea 仓库获取 Token:Settings -> Actions -> Runners -> Create new Runner
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN=你的Token
|
||||
49
runner/config.yaml
Normal file
49
runner/config.yaml
Normal file
@@ -0,0 +1,49 @@
|
||||
# Gitea Actions Runner 配置
|
||||
# 文档: https://docs.gitea.com/usage/actions/act-runner
|
||||
# 模式: Docker 模式 - Job 在独立的 Docker 容器中执行
|
||||
|
||||
log:
|
||||
# 日志级别: debug, info, warn, error
|
||||
level: info
|
||||
|
||||
runner:
|
||||
# Runner 注册信息存储文件
|
||||
file: .runner
|
||||
# 同时运行的任务数量
|
||||
capacity: 1
|
||||
# 环境变量传递给 job
|
||||
envs: {}
|
||||
# 任务超时时间
|
||||
timeout: 1h
|
||||
# 关机超时时间
|
||||
shutdown_timeout: 3h
|
||||
# 是否获取远程任务时不进行 TLS 验证(不推荐)
|
||||
insecure: false
|
||||
# 任务容器拉取策略: always, if-not-present, never
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
# Runner 标签 - 使用 Docker 模式,docker:latest 镜像自带 docker CLI
|
||||
labels:
|
||||
- "ubuntu-latest:docker://docker:latest"
|
||||
- "self-hosted:docker://docker:latest"
|
||||
|
||||
container:
|
||||
# 容器网络模式
|
||||
network: "host"
|
||||
# 是否启用特权模式
|
||||
privileged: false
|
||||
# 容器启动选项 - 挂载 docker.sock 和项目目录
|
||||
options: "-v /var/run/docker.sock:/var/run/docker.sock"
|
||||
# 工作目录父路径
|
||||
workdir_parent:
|
||||
# 有效的卷挂载 - 允许挂载的目录
|
||||
valid_volumes:
|
||||
- /**
|
||||
# Docker 主机
|
||||
docker_host: ""
|
||||
# 强制拉取镜像
|
||||
force_pull: false
|
||||
|
||||
host:
|
||||
# 主机工作目录
|
||||
workdir_parent:
|
||||
25
server/.dockerignore
Normal file
25
server/.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
# Ignore these files for docker build context
|
||||
# Binaries
|
||||
server
|
||||
billai-server
|
||||
*.exe
|
||||
*.dll
|
||||
|
||||
# Dependencies (if any are local and not in go.mod/go.sum, unlikely for Go)
|
||||
vendor/
|
||||
|
||||
# Logs and outputs
|
||||
*.log
|
||||
outputs/
|
||||
uploads/
|
||||
|
||||
# IDE config
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -14,7 +14,7 @@ type DeleteBillResponse struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteBill DELETE /api/bills/:id 删除清洗后的账单记录
|
||||
// DeleteBill POST /api/bills/:id/delete 删除清洗后的账单记录
|
||||
func DeleteBill(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
|
||||
@@ -1,61 +1,59 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"billai-server/config"
|
||||
"billai-server/model"
|
||||
"billai-server/service"
|
||||
"billai-server/repository"
|
||||
)
|
||||
|
||||
// Review 获取需要复核的记录
|
||||
func Review(c *gin.Context) {
|
||||
// 获取文件名参数
|
||||
fileName := c.Query("file")
|
||||
if fileName == "" {
|
||||
c.JSON(http.StatusBadRequest, model.ReviewResponse{
|
||||
// 获取数据
|
||||
repo := repository.GetRepository()
|
||||
if repo == nil {
|
||||
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
|
||||
Result: false,
|
||||
Message: "请提供文件名参数 (file)",
|
||||
Message: "数据库未连接",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建文件路径
|
||||
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
|
||||
filePath := filepath.Join(outputDirAbs, fileName)
|
||||
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, model.ReviewResponse{
|
||||
// 从MongoDB查询所有需要复核的账单
|
||||
bills, err := repo.GetBillsNeedReview()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
|
||||
Result: false,
|
||||
Message: "文件不存在: " + fileName,
|
||||
Message: "查询失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 判断文件格式
|
||||
format := "csv"
|
||||
if strings.HasSuffix(fileName, ".json") {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
// 提取需要复核的记录
|
||||
records := service.ExtractNeedsReview(filePath, format)
|
||||
|
||||
// 统计高低优先级数量
|
||||
// 统计高低优先级数量并转换为 ReviewRecord
|
||||
highCount := 0
|
||||
lowCount := 0
|
||||
for _, r := range records {
|
||||
if r.ReviewLevel == "HIGH" {
|
||||
records := make([]model.ReviewRecord, 0, len(bills))
|
||||
|
||||
for _, bill := range bills {
|
||||
if bill.ReviewLevel == "HIGH" {
|
||||
highCount++
|
||||
} else if r.ReviewLevel == "LOW" {
|
||||
} else if bill.ReviewLevel == "LOW" {
|
||||
lowCount++
|
||||
}
|
||||
|
||||
records = append(records, model.ReviewRecord{
|
||||
Time: bill.Time.Time().Format("2006-01-02 15:04:05"),
|
||||
Category: bill.Category,
|
||||
Merchant: bill.Merchant,
|
||||
Description: bill.Description,
|
||||
IncomeExpense: bill.IncomeExpense,
|
||||
Amount: fmt.Sprintf("%.2f", bill.Amount),
|
||||
Remark: bill.Remark,
|
||||
ReviewLevel: bill.ReviewLevel,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.ReviewResponse{
|
||||
|
||||
@@ -445,7 +445,12 @@ func (r *Repository) DeleteCleanedBillByID(id string) error {
|
||||
defer cancel()
|
||||
|
||||
filter := bson.M{"_id": oid}
|
||||
update := bson.M{"$set": bson.M{"is_deleted": true}}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"is_deleted": true,
|
||||
"updated_at": time.Now(), // 记录更新时间
|
||||
},
|
||||
}
|
||||
result, err := r.cleanedCollection.UpdateOne(ctx, filter, update)
|
||||
if err != nil {
|
||||
return fmt.Errorf("soft delete bill failed: %w", err)
|
||||
|
||||
@@ -63,7 +63,7 @@ func setupAPIRoutes(r *gin.Engine) {
|
||||
authed.POST("/bills/:id", handler.UpdateBill)
|
||||
|
||||
// 删除账单(软删除)
|
||||
authed.DELETE("/bills/:id", handler.DeleteBill)
|
||||
authed.POST("/bills/:id/delete", handler.DeleteBill)
|
||||
|
||||
// 手动创建账单
|
||||
authed.POST("/bills/manual", handler.CreateManualBills)
|
||||
|
||||
5
web/.dockerignore
Normal file
5
web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.git
|
||||
.DS_Store
|
||||
.svelte-kit
|
||||
build
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
3
web/src/app.d.ts
vendored
3
web/src/app.d.ts
vendored
@@ -8,6 +8,9 @@ declare global {
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
// Vite 注入的全局变量
|
||||
const __APP_VERSION__: string;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -128,17 +128,6 @@ export async function uploadBill(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取复核记录
|
||||
export async function getReviewRecords(fileName: string): Promise<ReviewResponse> {
|
||||
const response = await apiFetch(`${API_BASE}/api/review?file=${encodeURIComponent(fileName)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取月度统计(全部数据,不受筛选条件影响)
|
||||
export async function fetchMonthlyStats(): Promise<MonthlyStatsResponse> {
|
||||
const response = await apiFetch(`${API_BASE}/api/monthly-stats`);
|
||||
@@ -403,8 +392,8 @@ export interface DeleteBillResponse {
|
||||
|
||||
// 删除账单(软删除)
|
||||
export async function deleteBill(id: string): Promise<DeleteBillResponse> {
|
||||
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE'
|
||||
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}/delete`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
129
web/src/lib/components/ChangelogDrawer.svelte
Normal file
129
web/src/lib/components/ChangelogDrawer.svelte
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
|
||||
let { open = $bindable(false) } = $props();
|
||||
|
||||
// Changelog 内容(从 CHANGELOG.md 解析或硬编码)
|
||||
const changelog = [
|
||||
{
|
||||
version: '1.3.1',
|
||||
date: '2026-01-26',
|
||||
changes: {
|
||||
优化: [
|
||||
'版本号显示优化 - 侧边栏版本号按钮样式改进',
|
||||
'移至次级导航区域,与其他菜单项样式一致',
|
||||
'更新日志改用 Sheet 组件(右侧滑出),替代底部 Drawer',
|
||||
'统一暗色主题下的视觉效果'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '1.3.0',
|
||||
date: '2026-01-26',
|
||||
changes: {
|
||||
新增: [
|
||||
'京东账单支持 - 支持京东白条账单上传和清洗',
|
||||
'自动识别京东账单类型(交易流水 ZIP)',
|
||||
'解析京东白条账单 CSV 格式(含还款日期信息)',
|
||||
'京东专属分类映射配置',
|
||||
'支持京东外卖、京东平台商户等商户识别',
|
||||
'上传页面和账单列表页面添加"京东"选项'
|
||||
],
|
||||
优化: [
|
||||
'京东订单智能去重 - 上传京东账单时自动软删除其他来源中的京东订单',
|
||||
'分类推断复核等级优化 - 京东账单引入 LOW 复核等级',
|
||||
'京东平台商户关键词扩展'
|
||||
],
|
||||
技术改进: [
|
||||
'新增京东账单清理器',
|
||||
'新增京东专属配置',
|
||||
'后端新增软删除接口',
|
||||
'新增单元测试(11 个测试用例)'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '1.2.1',
|
||||
date: '2026-01-23',
|
||||
changes: {
|
||||
优化: [
|
||||
'智能复核快捷确认 - 在复核列表每行添加快捷确认按钮',
|
||||
'无需打开详情页面即可确认分类正确',
|
||||
'自动更新统计数据',
|
||||
'提升复核效率,支持快速批量确认'
|
||||
],
|
||||
文档: ['AGENTS.md 更新 - 精简为 150 行,专为 AI 编程助手设计']
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '1.2.0',
|
||||
date: '2026-01-25',
|
||||
changes: {
|
||||
新增: [
|
||||
'账单删除功能 - 支持在账单详情抽屉中删除账单(软删除)',
|
||||
'删除按钮带二次确认,防止误操作',
|
||||
'已删除的账单在所有查询中自动过滤'
|
||||
],
|
||||
技术改进: [
|
||||
'后端 MongoDB 查询方法添加软删除过滤',
|
||||
'新增 DELETE /api/bills/:id 接口'
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Content side="right" class="w-[400px] sm:w-[500px] overflow-hidden">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title class="text-xl font-semibold">版本更新日志</Sheet.Title>
|
||||
<Sheet.Description class="text-muted-foreground">
|
||||
查看 BillAI 的版本更新历史
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-6">
|
||||
<div class="space-y-8">
|
||||
{#each changelog as release}
|
||||
<div class="space-y-3">
|
||||
<!-- 版本号和日期 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold">v{release.version}</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar class="size-4" />
|
||||
<span>{release.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变更内容 -->
|
||||
<div class="space-y-4 pl-7 border-l-2 border-muted">
|
||||
{#each Object.entries(release.changes) as [category, items]}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold text-primary">{category}</h4>
|
||||
<ul class="space-y-1.5 text-sm text-muted-foreground">
|
||||
{#each items as item}
|
||||
<li class="flex gap-2 leading-relaxed">
|
||||
<span class="text-primary mt-1.5">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet.Footer class="border-t pt-4">
|
||||
<Button variant="outline" onclick={() => (open = false)} class="w-full">关闭</Button>
|
||||
</Sheet.Footer>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
@@ -16,6 +16,7 @@
|
||||
pageSize?: number;
|
||||
categories?: string[];
|
||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||
onDelete?: (deleted: UIBill) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -24,7 +25,8 @@
|
||||
showDescription = true,
|
||||
pageSize = 10,
|
||||
categories = [],
|
||||
onUpdate
|
||||
onUpdate,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
|
||||
// 排序状态
|
||||
@@ -112,6 +114,24 @@
|
||||
onUpdate?.(updated, original);
|
||||
}
|
||||
|
||||
function handleRecordDeleted(deleted: UIBill) {
|
||||
const idx = records.findIndex(r => r === deleted);
|
||||
const finalIdx = idx !== -1
|
||||
? idx
|
||||
: records.findIndex(r =>
|
||||
r.time === deleted.time &&
|
||||
r.merchant === deleted.merchant &&
|
||||
r.amount === deleted.amount
|
||||
);
|
||||
|
||||
if (finalIdx !== -1) {
|
||||
records.splice(finalIdx, 1);
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
onDelete?.(deleted);
|
||||
}
|
||||
|
||||
// 重置分页(当记录变化时)
|
||||
$effect(() => {
|
||||
records;
|
||||
@@ -280,4 +300,6 @@
|
||||
viewDescription="查看这笔支出的详细信息"
|
||||
editDescription="修改这笔支出的信息"
|
||||
onUpdate={handleRecordUpdated}
|
||||
onDelete={handleRecordDeleted}
|
||||
allowDelete={true}
|
||||
/>
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
records: UIBill[];
|
||||
categories?: string[];
|
||||
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||
onDelete?: (deleted: UIBill) => void;
|
||||
}
|
||||
|
||||
let { records = $bindable(), categories = [], onUpdate }: Props = $props();
|
||||
let { records = $bindable(), categories = [], onUpdate, onDelete }: Props = $props();
|
||||
|
||||
function handleRecordUpdated(updated: UIBill, original: UIBill) {
|
||||
// 更新 records 数组
|
||||
@@ -47,6 +48,28 @@
|
||||
onUpdate?.(updated, original);
|
||||
}
|
||||
|
||||
function handleRecordDeleted(deleted: UIBill) {
|
||||
const idx = records.findIndex(r =>
|
||||
r === deleted ||
|
||||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||
);
|
||||
if (idx !== -1) {
|
||||
records.splice(idx, 1);
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
const dateIdx = selectedDateRecords.findIndex(r =>
|
||||
r === deleted ||
|
||||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||
);
|
||||
if (dateIdx !== -1) {
|
||||
selectedDateRecords.splice(dateIdx, 1);
|
||||
selectedDateRecords = [...selectedDateRecords];
|
||||
}
|
||||
|
||||
onDelete?.(deleted);
|
||||
}
|
||||
|
||||
// Dialog 状态
|
||||
let dialogOpen = $state(false);
|
||||
let selectedDate = $state<Date | null>(null);
|
||||
@@ -923,6 +946,7 @@
|
||||
pageSize={8}
|
||||
{categories}
|
||||
onUpdate={handleRecordUpdated}
|
||||
onDelete={handleRecordDeleted}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
records: UIBill[];
|
||||
categories: string[]; // 可用的分类列表
|
||||
onUpdate?: (record: UIBill) => void;
|
||||
onDelete?: (record: UIBill) => void;
|
||||
}
|
||||
|
||||
let { records, categories, onUpdate }: Props = $props();
|
||||
let { records, categories, onUpdate, onDelete }: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let selectedRecord = $state<UIBill | null>(null);
|
||||
@@ -32,6 +33,26 @@
|
||||
selectedRecord = updated;
|
||||
onUpdate?.(updated);
|
||||
}
|
||||
|
||||
function handleRecordDeleted(deleted: UIBill) {
|
||||
const idx = records.findIndex(r => r === deleted);
|
||||
const finalIdx = idx !== -1
|
||||
? idx
|
||||
: records.findIndex(r =>
|
||||
r.time === deleted.time &&
|
||||
r.merchant === deleted.merchant &&
|
||||
r.amount === deleted.amount
|
||||
);
|
||||
|
||||
if (finalIdx !== -1) {
|
||||
records.splice(finalIdx, 1);
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
selectedRecord = null;
|
||||
selectedRank = 0;
|
||||
onDelete?.(deleted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
|
||||
@@ -80,6 +101,8 @@
|
||||
viewDescription="查看这笔支出的完整信息"
|
||||
editDescription="修改这笔支出的信息"
|
||||
onUpdate={handleRecordUpdated}
|
||||
onDelete={handleRecordDeleted}
|
||||
allowDelete={true}
|
||||
>
|
||||
{#snippet titleExtra({ isEditing })}
|
||||
{#if selectedRank <= 3 && !isEditing}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import ChangelogDrawer from '$lib/components/ChangelogDrawer.svelte';
|
||||
|
||||
// Icons
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
@@ -24,6 +25,10 @@
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import Bell from '@lucide/svelte/icons/bell';
|
||||
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
|
||||
// 版本号(从 Vite 编译时注入)
|
||||
const appVersion = __APP_VERSION__;
|
||||
|
||||
// Theme
|
||||
import {
|
||||
@@ -42,6 +47,7 @@
|
||||
let checkingHealth = $state(true);
|
||||
let isAuthenticated = $state(false);
|
||||
let currentUser = $state<AuthUser | null>(null);
|
||||
let changelogOpen = $state(false);
|
||||
|
||||
// 订阅认证状态
|
||||
$effect(() => {
|
||||
@@ -223,6 +229,18 @@
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
|
||||
<!-- 版本号 -->
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton>
|
||||
{#snippet child({ props })}
|
||||
<button {...props} onclick={() => changelogOpen = true} title="查看更新日志">
|
||||
<Info class="size-4" />
|
||||
<span>v{appVersion}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
@@ -231,6 +249,7 @@
|
||||
<!-- Footer: 用户信息 -->
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.Menu>
|
||||
<!-- 用户信息 -->
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
@@ -344,4 +363,7 @@
|
||||
</main>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
<!-- Changelog 抽屉 -->
|
||||
<ChangelogDrawer bind:open={changelogOpen} />
|
||||
{/if}
|
||||
|
||||
@@ -127,6 +127,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleBillDeleted(deleted: UIBill) {
|
||||
const idx = records.findIndex(r =>
|
||||
r.id === (deleted as unknown as { id?: string }).id ||
|
||||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||
);
|
||||
if (idx !== -1) {
|
||||
records.splice(idx, 1);
|
||||
records = [...records];
|
||||
}
|
||||
|
||||
const allIdx = allRecords.findIndex(r =>
|
||||
r.id === (deleted as unknown as { id?: string }).id ||
|
||||
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||
);
|
||||
if (allIdx !== -1) {
|
||||
allRecords.splice(allIdx, 1);
|
||||
allRecords = [...allRecords];
|
||||
}
|
||||
|
||||
if (deleted.incomeExpense === '支出') {
|
||||
backendTotalExpense = Math.max(0, backendTotalExpense - deleted.amount);
|
||||
} else if (deleted.incomeExpense === '收入') {
|
||||
backendTotalIncome = Math.max(0, backendTotalIncome - deleted.amount);
|
||||
}
|
||||
}
|
||||
|
||||
// 分类列表按数据中出现次数排序
|
||||
let sortedCategories = $derived(() => {
|
||||
const categoryCounts = new Map<string, number>();
|
||||
@@ -289,7 +315,12 @@
|
||||
<OverviewCards {totalStats} records={analysisRecords} />
|
||||
|
||||
<!-- 每日支出趋势图(按分类堆叠) - 使用全部数据 -->
|
||||
<DailyTrendChart records={allAnalysisRecords} categories={sortedCategories()} onUpdate={handleBillUpdated} />
|
||||
<DailyTrendChart
|
||||
records={allAnalysisRecords}
|
||||
categories={sortedCategories()}
|
||||
onUpdate={handleBillUpdated}
|
||||
onDelete={handleBillDeleted}
|
||||
/>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- 分类支出排行 -->
|
||||
@@ -307,7 +338,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Top 10 支出 -->
|
||||
<TopExpenses records={topExpenses} categories={sortedCategories()} onUpdate={handleBillUpdated} />
|
||||
<TopExpenses
|
||||
records={topExpenses}
|
||||
categories={sortedCategories()}
|
||||
onUpdate={handleBillUpdated}
|
||||
onDelete={handleBillDeleted}
|
||||
/>
|
||||
{:else}
|
||||
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
|
||||
<Card.Root>
|
||||
|
||||
@@ -2,10 +2,18 @@ import { defineConfig } from 'vitest/config';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit(), tailwindcss()],
|
||||
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version)
|
||||
},
|
||||
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user