Compare commits

..

16 Commits

Author SHA1 Message Date
clz
642ea2d3ef fix: 修复账单删除功能并支持分析页面删除操作
Some checks are pending
Deploy BillAI / Deploy to Production (push) Waiting to run
- 将删除接口从 DELETE /api/bills/:id 改为 POST /api/bills/:id/delete 以兼容 SvelteKit 代理
- 分析页面组件 (TopExpenses/BillRecordsTable/DailyTrendChart) 支持删除并同步更新统计数据
- Review 接口改为直接查询 MongoDB 而非读取文件
- 软删除时记录 updated_at 时间戳
- 添加 .dockerignore 文件优化构建
- 完善 AGENTS.md 文档
2026-02-16 22:28:49 +08:00
CHE LIANG ZHAO
a5f1a370c7 feat: 改用 Docker 模式运行 Gitea Actions
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 26s
2026-01-26 18:30:28 +08:00
CHE LIANG ZHAO
b7399d185f 不要reset
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 18:20:49 +08:00
CHE LIANG ZHAO
5537e1234d fix: 挂载 Docker CLI 插件目录以支持 docker compose 命令
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 18:14:14 +08:00
CHE LIANG ZHAO
f6437b2ada fix: 挂载 docker 和 docker-compose 命令到 Runner 容器
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 18:10:38 +08:00
CHE LIANG ZHAO
cc0623c15a fix: 添加 git safe.directory 配置解决权限问题
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 1s
2026-01-26 18:06:38 +08:00
CHE LIANG ZHAO
cb4273fad0 chore: 恢复 runner 标签为 ubuntu-latest
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 18:05:00 +08:00
CHE LIANG ZHAO
99ec5ea0a4 chore: workflow 添加部署路径日志输出
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 17:56:30 +08:00
CHE LIANG ZHAO
89e1e74b76 chore: 更新 runner 标签为 billAI
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 17:27:24 +08:00
CHE LIANG ZHAO
ed0a44851d feat: 添加 Gitea Actions 自动部署功能
Some checks failed
Deploy BillAI / Deploy to Production (push) Failing after 0s
2026-01-26 17:21:12 +08:00
CHE LIANG ZHAO
a1eebd0b3f fix: 优化版本号显示样式 (v1.3.1) 2026-01-26 16:37:00 +08:00
CHE LIANG ZHAO
ef34a1bb5d fix: 修复 Vite allow list 错误 2026-01-26 16:24:39 +08:00
CHE LIANG ZHAO
ab9aab7beb fix: 修复版本号导入 Vite serving allow list 错误 2026-01-26 16:22:27 +08:00
CHE LIANG ZHAO
61d26fc971 feat: 在 web 页面显示版本号和更新日志 2026-01-26 16:06:06 +08:00
CHE LIANG ZHAO
f537b53ebd chore: release v1.3.0 - 京东账单支持 2026-01-26 15:36:05 +08:00
CHE LIANG ZHAO
b654265d96 docs: add gitea webhook deploy plan 2026-01-26 15:25:23 +08:00
28 changed files with 890 additions and 152 deletions

View 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
View File

@@ -3,139 +3,125 @@
Guidelines for AI coding agents working on BillAI - a microservices bill analysis system. Guidelines for AI coding agents working on BillAI - a microservices bill analysis system.
## Architecture ## Architecture
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript - `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend Proxy & UI)
- `server/` - Go 1.21 + Gin + MongoDB - `server/` - Go 1.21 + Gin + MongoDB (Main API & Data Storage)
- `analyzer/` - Python 3.12 + FastAPI - `analyzer/` - Python 3.12 + FastAPI (Data Cleaning & Analysis Service)
## Build/Lint/Test Commands ## Build/Lint/Test Commands
### Frontend (web/) ### Frontend (web/)
**Working Directory:** `/Users/clz/Projects/BillAI/web`
```bash ```bash
npm run dev # Start dev server npm run dev # Start dev server
npm run build # Production build npm run build # Production build
npm run check # TypeScript check npm run check # TypeScript check (svelte-check)
npm run lint # Prettier + ESLint npm run lint # Prettier + ESLint
npm run format # Format code npm run format # Format code (Prettier)
npm run test # Run all tests npm run test:unit # Run all unit tests (Vitest)
npx vitest run src/routes/+page.spec.ts # Single test file npx vitest run src/routes/+page.spec.ts # Run single test file
npx vitest run -t "test name" # Test by name npx vitest run -t "test name" # Run test by name pattern
``` ```
### Backend (server/) ### Backend (server/)
**Working Directory:** `/Users/clz/Projects/BillAI/server`
```bash ```bash
go run . # Start server go run . # Start server
go build . # Build binary go build -o server . # Build binary
go mod tidy # Clean dependencies go mod tidy # Clean dependencies
go test ./... # All tests go test ./... # Run all tests
go test ./handler/... # Package tests go test ./handler/... # Run package tests
go test -run TestName # Single test go test -run TestName # Run single test function
go test -v ./handler/... # Run tests with verbose output
``` ```
### Analyzer (analyzer/) ### Analyzer (analyzer/)
**Working Directory:** `/Users/clz/Projects/BillAI/analyzer`
```bash ```bash
python server.py # Start FastAPI server python server.py # Start FastAPI server directly
pytest # All tests uvicorn server:app --reload # Start with hot reload
pytest test_file.py # Single file pytest # Run all tests
pytest -k "test_name" # Test by pattern 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 ### Docker
**Working Directory:** `/Users/clz/Projects/BillAI`
```bash ```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 logs -f server # Follow service logs
docker-compose down # Stop all services
``` ```
## Code Style ## Code Style
### TypeScript/Svelte ### General
**Prettier config:** Tabs, single quotes, no trailing commas, width 100 - **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/Svelte (web/)
```typescript - **Formatting:** Prettier (Tabs, single quotes, no trailing commas, printWidth 100).
import { browser } from '$app/environment'; // SvelteKit - **Naming:** `PascalCase` for types/components/interfaces, `camelCase` for variables/functions.
import { auth } from '$lib/stores/auth'; // Internal - **Imports:** Use `$lib` alias for internal imports.
import type { UIBill } from '$lib/models/bill'; ```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:** ### Go Backend (server/)
```typescript - **Structure:** `handler` (HTTP) → `service` (Logic) → `repository` (DB) → `model` (Structs).
export interface UploadResponse { - **Tags:** Use `json` (snake_case) and `form` tags. Use `omitempty` for optional fields.
result: boolean; ```go
message: string; type UpdateBillRequest struct {
data?: UploadData; 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) ### Python Analyzer (analyzer/)
- **Style:** PEP 8. Use `snake_case` for variables/functions.
**Error handling:** - **Type Hints:** Mandatory for function arguments and return types.
```typescript - **Models:** Use `pydantic.BaseModel` for API schemas.
if (!response.ok) { ```python
throw new Error(`HTTP ${response.status}`); class CleanRequest(BaseModel):
} input_path: str
// Handle 401 -> logout redirect bill_type: Optional[str] = "auto"
``` ```
- **Docstrings:** Use triple quotes. Chinese descriptions are common for API docs.
### 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)
```
## Key Patterns ## 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 - **File Processing:**
- Flow: Upload (ZIP/XLSX) -> Extract/Convert (to UTF-8 CSV) -> Clean (normalize columns) -> Import to DB.
**Testing:** Vitest + Playwright for frontend, Go test for backend - `analyzer` uses `openpyxl` for Excel and regex for cleaning text.
## Important Files ## Important Files
- `web/src/lib/api.ts` - API client - `web/src/lib/api.ts` - Centralized API client methods.
- `web/src/lib/models/` - UI data models - `web/src/lib/models/*.ts` - Frontend data models (should match backend JSON).
- `server/handler/` - HTTP handlers - `server/handler/*.go` - HTTP endpoint definitions.
- `server/service/` - Business logic - `server/repository/mongo.go` - MongoDB connection and queries.
- `server/model/` - Go data structures - `analyzer/server.py` - FastAPI entry point and routing.
- `analyzer/cleaners/` - Bill processing - `analyzer/cleaners/*.py` - Specific logic for Alipay/Wechat/JD bills.
- `mock_data/*.zip` - Test data (password: 123456)

View File

@@ -5,6 +5,49 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/) 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 版本号遵循 [语义化版本](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 ## [1.2.0] - 2026-01-25
### 新增 ### 新增

20
TODO.md
View File

@@ -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 实时状态推送** - [ ] **SSE 实时状态推送**
- 服务器实现 `/events` SSE 端点 - 服务器实现 `/events` SSE 端点
- 前端使用 EventSource 接收状态 - 前端使用 EventSource 接收状态

13
analyzer/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*$py.class
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.git
.gitignore
.DS_Store

View 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
View 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
View 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

View 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

3
runner/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Gitea Runner 配置
# 从 Gitea 仓库获取 TokenSettings -> Actions -> Runners -> Create new Runner
GITEA_RUNNER_REGISTRATION_TOKEN=你的Token

49
runner/config.yaml Normal file
View 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
View 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

View File

@@ -14,7 +14,7 @@ type DeleteBillResponse struct {
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
} }
// DeleteBill DELETE /api/bills/:id 删除清洗后的账单记录 // DeleteBill POST /api/bills/:id/delete 删除清洗后的账单记录
func DeleteBill(c *gin.Context) { func DeleteBill(c *gin.Context) {
id := strings.TrimSpace(c.Param("id")) id := strings.TrimSpace(c.Param("id"))
if id == "" { if id == "" {

View File

@@ -1,61 +1,59 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"billai-server/config"
"billai-server/model" "billai-server/model"
"billai-server/service" "billai-server/repository"
) )
// Review 获取需要复核的记录 // Review 获取需要复核的记录
func Review(c *gin.Context) { func Review(c *gin.Context) {
// 获取文件名参 // 获取数
fileName := c.Query("file") repo := repository.GetRepository()
if fileName == "" { if repo == nil {
c.JSON(http.StatusBadRequest, model.ReviewResponse{ c.JSON(http.StatusInternalServerError, model.ReviewResponse{
Result: false, Result: false,
Message: "请提供文件名参数 (file)", Message: "数据库未连接",
}) })
return return
} }
// 构建文件路径 // 从MongoDB查询所有需要复核的账单
outputDirAbs := config.ResolvePath(config.Global.OutputDir) bills, err := repo.GetBillsNeedReview()
filePath := filepath.Join(outputDirAbs, fileName) if err != nil {
c.JSON(http.StatusInternalServerError, model.ReviewResponse{
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, model.ReviewResponse{
Result: false, Result: false,
Message: "文件不存在: " + fileName, Message: "查询失败: " + err.Error(),
}) })
return return
} }
// 判断文件格式 // 统计高低优先级数量并转换为 ReviewRecord
format := "csv"
if strings.HasSuffix(fileName, ".json") {
format = "json"
}
// 提取需要复核的记录
records := service.ExtractNeedsReview(filePath, format)
// 统计高低优先级数量
highCount := 0 highCount := 0
lowCount := 0 lowCount := 0
for _, r := range records { records := make([]model.ReviewRecord, 0, len(bills))
if r.ReviewLevel == "HIGH" {
for _, bill := range bills {
if bill.ReviewLevel == "HIGH" {
highCount++ highCount++
} else if r.ReviewLevel == "LOW" { } else if bill.ReviewLevel == "LOW" {
lowCount++ 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{ c.JSON(http.StatusOK, model.ReviewResponse{

View File

@@ -445,7 +445,12 @@ func (r *Repository) DeleteCleanedBillByID(id string) error {
defer cancel() defer cancel()
filter := bson.M{"_id": oid} 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) result, err := r.cleanedCollection.UpdateOne(ctx, filter, update)
if err != nil { if err != nil {
return fmt.Errorf("soft delete bill failed: %w", err) return fmt.Errorf("soft delete bill failed: %w", err)

View File

@@ -63,7 +63,7 @@ func setupAPIRoutes(r *gin.Engine) {
authed.POST("/bills/:id", handler.UpdateBill) 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) authed.POST("/bills/manual", handler.CreateManualBills)

5
web/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.git
.DS_Store
.svelte-kit
build

View File

@@ -1,7 +1,7 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "1.2.1", "version": "1.3.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

3
web/src/app.d.ts vendored
View File

@@ -8,6 +8,9 @@ declare global {
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
// Vite 注入的全局变量
const __APP_VERSION__: string;
} }
export {}; export {};

View File

@@ -128,17 +128,6 @@ export async function uploadBill(
return response.json(); 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> { export async function fetchMonthlyStats(): Promise<MonthlyStatsResponse> {
const response = await apiFetch(`${API_BASE}/api/monthly-stats`); const response = await apiFetch(`${API_BASE}/api/monthly-stats`);
@@ -403,8 +392,8 @@ export interface DeleteBillResponse {
// 删除账单(软删除) // 删除账单(软删除)
export async function deleteBill(id: string): Promise<DeleteBillResponse> { export async function deleteBill(id: string): Promise<DeleteBillResponse> {
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, { const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}/delete`, {
method: 'DELETE' method: 'POST'
}); });
if (!response.ok) { if (!response.ok) {

View 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>

View File

@@ -16,6 +16,7 @@
pageSize?: number; pageSize?: number;
categories?: string[]; categories?: string[];
onUpdate?: (updated: UIBill, original: UIBill) => void; onUpdate?: (updated: UIBill, original: UIBill) => void;
onDelete?: (deleted: UIBill) => void;
} }
let { let {
@@ -24,7 +25,8 @@
showDescription = true, showDescription = true,
pageSize = 10, pageSize = 10,
categories = [], categories = [],
onUpdate onUpdate,
onDelete
}: Props = $props(); }: Props = $props();
// 排序状态 // 排序状态
@@ -112,6 +114,24 @@
onUpdate?.(updated, original); 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(() => { $effect(() => {
records; records;
@@ -280,4 +300,6 @@
viewDescription="查看这笔支出的详细信息" viewDescription="查看这笔支出的详细信息"
editDescription="修改这笔支出的信息" editDescription="修改这笔支出的信息"
onUpdate={handleRecordUpdated} onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
allowDelete={true}
/> />

View File

@@ -18,9 +18,10 @@
records: UIBill[]; records: UIBill[];
categories?: string[]; categories?: string[];
onUpdate?: (updated: UIBill, original: UIBill) => void; 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) { function handleRecordUpdated(updated: UIBill, original: UIBill) {
// 更新 records 数组 // 更新 records 数组
@@ -46,6 +47,28 @@
// 传播到父组件 // 传播到父组件
onUpdate?.(updated, original); 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 状态 // Dialog 状态
let dialogOpen = $state(false); let dialogOpen = $state(false);
@@ -923,6 +946,7 @@
pageSize={8} pageSize={8}
{categories} {categories}
onUpdate={handleRecordUpdated} onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
/> />
</div> </div>
{:else} {:else}

View File

@@ -8,9 +8,10 @@
records: UIBill[]; records: UIBill[];
categories: string[]; // 可用的分类列表 categories: string[]; // 可用的分类列表
onUpdate?: (record: UIBill) => void; 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 dialogOpen = $state(false);
let selectedRecord = $state<UIBill | null>(null); let selectedRecord = $state<UIBill | null>(null);
@@ -32,6 +33,26 @@
selectedRecord = updated; selectedRecord = updated;
onUpdate?.(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> </script>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1"> <Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
@@ -80,6 +101,8 @@
viewDescription="查看这笔支出的完整信息" viewDescription="查看这笔支出的完整信息"
editDescription="修改这笔支出的信息" editDescription="修改这笔支出的信息"
onUpdate={handleRecordUpdated} onUpdate={handleRecordUpdated}
onDelete={handleRecordDeleted}
allowDelete={true}
> >
{#snippet titleExtra({ isEditing })} {#snippet titleExtra({ isEditing })}
{#if selectedRank <= 3 && !isEditing} {#if selectedRank <= 3 && !isEditing}

View File

@@ -10,6 +10,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Avatar from '$lib/components/ui/avatar'; import * as Avatar from '$lib/components/ui/avatar';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import ChangelogDrawer from '$lib/components/ChangelogDrawer.svelte';
// Icons // Icons
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
@@ -24,6 +25,10 @@
import User from '@lucide/svelte/icons/user'; import User from '@lucide/svelte/icons/user';
import Bell from '@lucide/svelte/icons/bell'; import Bell from '@lucide/svelte/icons/bell';
import Sparkles from '@lucide/svelte/icons/sparkles'; import Sparkles from '@lucide/svelte/icons/sparkles';
import Info from '@lucide/svelte/icons/info';
// 版本号(从 Vite 编译时注入)
const appVersion = __APP_VERSION__;
// Theme // Theme
import { import {
@@ -42,6 +47,7 @@
let checkingHealth = $state(true); let checkingHealth = $state(true);
let isAuthenticated = $state(false); let isAuthenticated = $state(false);
let currentUser = $state<AuthUser | null>(null); let currentUser = $state<AuthUser | null>(null);
let changelogOpen = $state(false);
// 订阅认证状态 // 订阅认证状态
$effect(() => { $effect(() => {
@@ -223,6 +229,18 @@
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
{/each} {/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.Menu>
</Sidebar.GroupContent> </Sidebar.GroupContent>
</Sidebar.Group> </Sidebar.Group>
@@ -231,6 +249,7 @@
<!-- Footer: 用户信息 --> <!-- Footer: 用户信息 -->
<Sidebar.Footer> <Sidebar.Footer>
<Sidebar.Menu> <Sidebar.Menu>
<!-- 用户信息 -->
<Sidebar.MenuItem> <Sidebar.MenuItem>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
@@ -344,4 +363,7 @@
</main> </main>
</Sidebar.Inset> </Sidebar.Inset>
</Sidebar.Provider> </Sidebar.Provider>
<!-- Changelog 抽屉 -->
<ChangelogDrawer bind:open={changelogOpen} />
{/if} {/if}

View File

@@ -126,6 +126,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(() => { let sortedCategories = $derived(() => {
@@ -289,7 +315,12 @@
<OverviewCards {totalStats} records={analysisRecords} /> <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"> <div class="grid gap-6 lg:grid-cols-2">
<!-- 分类支出排行 --> <!-- 分类支出排行 -->
@@ -307,7 +338,12 @@
</div> </div>
<!-- Top 10 支出 --> <!-- Top 10 支出 -->
<TopExpenses records={topExpenses} categories={sortedCategories()} onUpdate={handleBillUpdated} /> <TopExpenses
records={topExpenses}
categories={sortedCategories()}
onUpdate={handleBillUpdated}
onDelete={handleBillDeleted}
/>
{:else} {:else}
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 --> <!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
<Card.Root> <Card.Root>

View File

@@ -2,10 +2,18 @@ import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright'; import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/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({ export default defineConfig({
plugins: [sveltekit(), tailwindcss()], plugins: [sveltekit(), tailwindcss()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version)
},
server: { server: {
proxy: { proxy: {
'/api': { '/api': {