From 02de11caac335e012761980efc46ac005de47027 Mon Sep 17 00:00:00 2001 From: clz Date: Mon, 23 Mar 2026 19:16:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20Excel=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 /api/bills/export 接口,支持当前筛选条件导出全部记录 - 使用 excelize 库生成 xlsx 格式文件 - 前端账单管理页面添加导出按钮 - 更新 Go 版本到 1.24 以支持 excelize 依赖 --- AGENTS.md | 224 +++++++++--------------------- server/Dockerfile | 2 +- server/config.yaml | 2 +- server/go.mod | 18 ++- server/go.sum | 23 +++ server/handler/export.go | 134 ++++++++++++++++++ server/router/router.go | 3 + web/src/lib/api.ts | 40 ++++++ web/src/routes/bills/+page.svelte | 39 +++++- 9 files changed, 312 insertions(+), 173 deletions(-) create mode 100644 server/handler/export.go diff --git a/AGENTS.md b/AGENTS.md index 4e341d3..8c78b97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,208 +3,112 @@ Guidelines for AI coding agents working on BillAI - a microservices bill analysis system. ## Architecture -- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend Proxy & UI, port 3000) -- `server/` - Go 1.21 + Gin + MongoDB (Main API & Data Storage, port 8080) -- `analyzer/` - Python 3.12 + FastAPI (Data Cleaning & Analysis Service, port 8001) +- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend, port 3000) +- `server/` - Go 1.21 + Gin + MongoDB (API, port 8080) +- `analyzer/` - Python 3.12 + FastAPI (Data cleaning, port 8001) -The SvelteKit frontend acts as a **proxy**: all `/api/*` browser requests are forwarded by -`web/src/routes/api/[...path]/+server.ts` to the Go backend. The browser never contacts Go -directly. `API_URL` env var controls the target (`http://server:8080` in Docker, -`http://localhost:8080` in local dev). +SvelteKit proxies `/api/*` requests to Go backend via `web/src/routes/api/[...path]/+server.ts`. ## Build/Lint/Test Commands ### Frontend (web/) -**Working Directory:** `/Users/clz/Projects/BillAI/web` - ```bash -npm run dev # Start Vite dev server -npm run build # Production build (adapter-node) -npm run preview # Preview production build -npm run check # TypeScript check (svelte-check) -npm run lint # Prettier --check + ESLint -npm run format # Format with Prettier -npm run test # Run all unit tests once (CI mode) -npm run test:unit # Run unit tests in watch mode -npx vitest run src/routes/+page.spec.ts # Run single test file -npx vitest run -t "test name pattern" # Run tests by name pattern +npm run dev # Start dev server +npm run build # Production build +npm run check # TypeScript check +npm run lint # Prettier + ESLint +npm run format # Format with Prettier +npm run test # Run all tests (CI mode) +npx vitest run src/xxx.spec.ts # Run single test file +npx vitest run -t "pattern" # Run by name pattern ``` ### Backend (server/) -**Working Directory:** `/Users/clz/Projects/BillAI/server` - ```bash -go run . # Start server -go build -o server . # Build binary -go mod tidy # Clean dependencies -go test ./... # Run all tests -go test ./handler/... # Run handler package tests -go test -run TestName ./... # Run single test function -go test -v ./handler/... # Verbose test output +go run . # Start server +go build -o server . # Build binary +go test ./... # Run all tests +go test ./handler/... # Run handler tests +go test -run TestName ./... # Run single test +go test -v ./handler/... # Verbose output ``` ### Analyzer (analyzer/) -**Working Directory:** `/Users/clz/Projects/BillAI/analyzer` - ```bash -python server.py # Start FastAPI server directly -uvicorn server:app --reload # Start with hot reload -pytest # Run all tests -pytest test_jd_cleaner.py # Run single test file -pytest -k "test_name" # Run test by name pattern -pip install -r requirements.txt # Install dependencies +python server.py # Start FastAPI +uvicorn server:app --reload # Hot reload +pytest # Run all tests +pytest test_jd_cleaner.py # Single test file +pytest -k "pattern" # Run by pattern ``` ### Docker -**Working Directory:** `/Users/clz/Projects/BillAI` - ```bash -docker-compose up -d --build # Start/rebuild all services -docker-compose logs -f server # Follow service logs -docker-compose down # Stop all services +docker-compose up -d --build # Start/rebuild all services +docker-compose logs -f server # Follow logs +docker-compose down # Stop services ``` ## Code Style ### General -- **Comments:** Existing comments often use Chinese for business logic explanations. Maintain this - style where appropriate; 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`. +- **Comments:** Chinese common for business logic; English for technical. +- **Conventions:** Follow existing patterns. Check `package.json`/`go.mod`/`requirements.txt` before adding dependencies. ### TypeScript/Svelte (web/) -- **Formatting:** Prettier — tabs, single quotes, no trailing commas, printWidth 100, - `prettier-plugin-svelte`. -- **Naming:** `PascalCase` for types/interfaces/components, `camelCase` for variables/functions. -- **Imports:** Use `$lib` alias for internal imports and `$app/*` for SvelteKit builtins. Never - use relative paths for lib-level modules. - ```typescript - import { browser } from '$app/environment' - import { goto } from '$app/navigation' - import { auth } from '$lib/stores/auth' - import type { UIBill } from '$lib/models/bill' - import Upload from '@lucide/svelte/icons/upload' - ``` -- **Svelte 5 runes:** Use the new runes API — `$state`, `$derived`, `$effect`, `$props`. Event - handlers use `onclick={fn}` syntax (not legacy `on:click`). -- **Types:** Define `export interface` for all data models. Frontend models use `camelCase` fields - (`UIBill`); API responses use `snake_case` (`CleanedBill`). Provide explicit converter functions - (e.g., `cleanedBillToUIBill`, `uiBillToUpdateBillRequest`) in `web/src/lib/models/bill.ts`. -- **Error Handling:** Check `response.ok`; throw `new Error(\`HTTP ${response.status}\`)` for the - UI to catch. On 401, call `auth.logout()` and redirect to `/login`. -- **Auth pattern:** `createAuthStore()` factory in `$lib/stores/auth.ts`. Token stored in - `localStorage` under key `auth`. All API calls go through `apiFetch()` in `$lib/api.ts`, which - injects `Authorization: Bearer ` and handles 401 centrally. -- **Testing:** Vitest + `vitest-browser-svelte` + Playwright. Test files co-located with routes - as `*.spec.ts`. Use `describe` / `it` / `expect` from vitest, `render` from - `vitest-browser-svelte`. +- **Formatting:** Prettier (tabs, single quotes, printWidth 100) +- **Naming:** `PascalCase` for types/components, `camelCase` for variables +- **Imports:** Use `$lib` alias, `$app/*` for SvelteKit builtins. No relative paths for lib modules. +- **Svelte 5:** Use runes (`$state`, `$derived`, `$effect`, `$props`). Event: `onclick={fn}`. +- **Types:** `export interface` for models. Frontend `camelCase`, API `snake_case`. Converters in `$lib/models/bill.ts`. +- **Error Handling:** Check `response.ok`, throw `Error(\`HTTP ${status}\`)`. On 401: `auth.logout()` + redirect. +- **Auth:** `createAuthStore()` in `$lib/stores/auth.ts`. Token in `localStorage` key `auth`. Use `apiFetch()` in `$lib/api.ts`. ### Go Backend (server/) -- **Layer structure:** `handler` (HTTP) → `service` (logic) → `adapter` (external Python service) - and `repository` (DB) → `model` (structs). Handlers must not contain business logic. -- **Struct tags:** JSON uses `snake_case`. `omitempty` on optional response fields. Use `form` tags - for query/form binding. Use pointer fields (`*string`) for optional patch request fields. Sensitive - fields get `json:"-"`. - ```go - type CleanedBill struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` - BillType string `bson:"bill_type" json:"bill_type"` - } - type UpdateBillRequest struct { - Category *string `json:"category,omitempty"` - } - ``` -- **Error Handling:** Return `500` for DB/internal errors, `400` for bad requests, `404` for not - found. Wrap errors with context using `fmt.Errorf("context: %w", err)`. Check - `err == repository.ErrNotFound` for 404 disambiguation. Use `Result bool` (not `Success`) in - response envelopes. - ```go - if err != nil { - c.JSON(http.StatusInternalServerError, Response{Result: false, Message: err.Error()}) - return - } - ``` -- **Response envelope:** Most endpoints: `Result bool`, `Message string`, `Data *T`. Auth endpoints - use `success bool`, `error string`, `data interface{}`. -- **Interfaces:** Use `adapter.Cleaner` and `repository.BillRepository` interfaces. Access global - singletons via `adapter.GetCleaner()` and `repository.GetRepository()`. -- **Time:** Use the custom `LocalTime` type (wraps `time.Time`) for all timestamp fields. It - serializes as `"2006-01-02 15:04:05"` in both JSON and BSON, preserving local time. -- **Soft delete:** Bills are never hard-deleted. All queries must filter `is_deleted: false`. +- **Layer:** `handler` → `service` → `adapter`/`repository` → `model`. No business logic in handlers. +- **Struct tags:** JSON `snake_case`, `omitempty` optional. Pointer for optional patch fields. Sensitive: `json:"-"`. +- **Error handling:** 500 for DB errors, 400 for bad requests, 404 not found. Wrap with `fmt.Errorf("context: %w", err)`. +- **Response:** `Result bool`, `Message`, `Data *T`. Auth: `success bool`, `error`, `data`. +- **Time:** Use custom `LocalTime` type (serializes as `2006-01-02 15:04:05`). +- **Soft delete:** Never hard-delete. Filter `is_deleted: false` in queries. ### Python Analyzer (analyzer/) -- **Style:** PEP 8. `snake_case` for variables, functions, and filenames. `UPPER_CASE` for - module-level constants. Prefix private module globals with `_`. -- **Type Hints:** Mandatory for all function arguments and return types. Use `Optional[str]` from - `typing` or `str | None` (Python 3.10+ union syntax). -- **Models:** Use `pydantic.BaseModel` for all API request/response schemas. - ```python - class CleanRequest(BaseModel): - input_path: str - output_path: str - year: Optional[str] = None - bill_type: Optional[str] = "auto" - ``` -- **FastAPI patterns:** Use `HTTPException(status_code=400, detail=message)` for user errors. - Manage temporary files with `tempfile.NamedTemporaryFile` + `os.unlink` in `finally` blocks. -- **Cleaner classes:** Extend `BaseCleaner(ABC)` from `cleaners/base.py`. Implement `clean()` and - optionally `reclassify()`. Category inference reads rules from `config/category.yaml` via - `yaml.safe_load`. -- **Docstrings:** Triple-quoted. Chinese descriptions are common for API endpoint docs. +- **Style:** PEP 8. `snake_case` variables, `UPPER_CASE` constants. Prefix private globals with `_`. +- **Type hints:** Mandatory. Use `Optional[str]` or `str | None`. +- **Models:** `pydantic.BaseModel` for API schemas. +- **Cleaners:** Extend `BaseCleaner(ABC)` from `cleaners/base.py`. Category rules in `config/category.yaml`. ## Key Patterns ### API Flow ``` -Browser → SvelteKit proxy (/api/[...path]/+server.ts) - → Go server (Gin, AuthRequired middleware) - → handler → service → adapter.GetCleaner() → HTTP POST to Python FastAPI - → repository.GetRepository() → MongoDB +Browser → SvelteKit proxy → Go (Gin) → handler → service → adapter → Python FastAPI + └→ repository → MongoDB ``` ### Authentication -- JWT (HS256). Token in `localStorage` under key `auth`. -- Header: `Authorization: Bearer `. -- `middleware.AuthRequired()` wraps all `/api/*` routes except `/api/auth/*`. -- Passwords in `config.yaml` support plaintext or SHA-256 hashed values. -- 401 anywhere → `auth.logout()` + redirect to `/login`. +- JWT (HS256). Token in `localStorage` key `auth`. Header: `Authorization: Bearer `. +- `middleware.AuthRequired()` wraps `/api/*` (except `/api/auth/*`). +- 401 anywhere → `auth.logout()` + redirect `/login`. ### File Processing -Upload flow: Upload (ZIP/XLSX) → Extract → Convert to UTF-8 CSV (Python `/convert`) → -Auto-detect bill type → Deduplicate against DB → Clean/normalize (Python `/clean/upload`) → -Save raw + cleaned bills to MongoDB. - -Deduplication: raw bills check `transaction_id`; cleaned bills check -`transaction_id + merchant_order_no`. JD bills trigger soft-deletion of overlapping records in -other sources to prevent double-counting. +Upload: ZIP/XLSX → Extract → Convert UTF-8 CSV → Detect bill type → Deduplicate → Clean → Save to MongoDB. ### Adapter (Go ↔ Python) -`adapter.Cleaner` interface has two implementations: HTTP-based (`adapter/http`, default) and -subprocess-based (`adapter/python`, legacy). Controlled by `ANALYZER_MODE` env var. +`adapter.Cleaner` interface: HTTP (`adapter/http`, default) or subprocess (`adapter/python`). Set via `ANALYZER_MODE` env var. ## Important Files | File | Role | |---|---| -| `web/src/lib/api.ts` | Central API client; `apiFetch()` injects auth and handles 401 | -| `web/src/lib/stores/auth.ts` | Auth state; JWT in localStorage; login/logout/validate | -| `web/src/lib/models/bill.ts` | `UIBill` model + converters to/from API `CleanedBill` shape | -| `web/src/routes/api/[...path]/+server.ts` | SvelteKit proxy to Go backend | -| `server/main.go` | Entry point; wires config, adapters, repository, router | -| `server/config/config.go` | YAML + env config; priority: defaults → config.yaml → env vars | -| `server/router/router.go` | All route definitions and middleware assignment | -| `server/middleware/auth.go` | JWT validation + user context injection | -| `server/handler/upload.go` | Full upload pipeline (extract → convert → clean → store) | -| `server/handler/bills.go` | List/filter bills with pagination and monthly stats | -| `server/model/bill.go` | `RawBill`, `CleanedBill`, `MonthlyStat`; custom `LocalTime` type | -| `server/adapter/adapter.go` | `Cleaner` interface definition | -| `server/repository/repository.go` | `BillRepository` interface (14 persistence methods) | -| `server/repository/mongo/repository.go` | MongoDB implementation with aggregation pipelines | -| `analyzer/server.py` | FastAPI entry point; `/health`, `/clean`, `/convert`, `/detect` routes | -| `analyzer/cleaners/base.py` | `BaseCleaner` ABC; shared filtering and output logic | -| `analyzer/cleaners/alipay.py` | Alipay-specific normalization | -| `analyzer/cleaners/wechat.py` | WeChat-specific normalization | -| `analyzer/cleaners/jd.py` | JD (京东) normalization and 3-level review scoring | -| `analyzer/category.py` | `infer_category()` using YAML keyword rules | -| `analyzer/converter.py` | xlsx→csv (openpyxl), GBK→UTF-8 re-encoding, type detection | -| `server/config.yaml` | Server port, MongoDB URI, JWT settings, user list | -| `docker-compose.yaml` | 5 services: web, server, analyzer, mongodb, mongo-express | +| `web/src/lib/api.ts` | Central API client, auth injection | +| `web/src/lib/stores/auth.ts` | Auth state, JWT handling | +| `web/src/lib/models/bill.ts` | UIBill model + converters | +| `server/main.go` | Entry point | +| `server/handler/upload.go` | Full upload pipeline | +| `server/handler/bills.go` | List/filter bills | +| `server/model/bill.go` | Bill models, LocalTime type | +| `server/adapter/adapter.go` | Cleaner interface | +| `server/repository/mongo/repository.go` | MongoDB implementation | +| `analyzer/server.py` | FastAPI entry | +| `analyzer/cleaners/base.py` | BaseCleaner ABC | +| `analyzer/category.py` | Category inference | diff --git a/server/Dockerfile b/server/Dockerfile index 458f4a6..2bf9d92 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -2,7 +2,7 @@ # 多阶段构建:编译阶段 + 运行阶段 # ===== 编译阶段 ===== -FROM golang:1.21-alpine AS builder +FROM golang:1.24-alpine AS builder WORKDIR /build diff --git a/server/config.yaml b/server/config.yaml index 1004990..50d7c7e 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -1,7 +1,7 @@ # BillAI 服务器配置文件 # 应用版本 -version: "1.0.7" +version: "1.0.8" # 服务配置 server: diff --git a/server/go.mod b/server/go.mod index 127c341..93c7229 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,13 +1,13 @@ module billai-server -go 1.21 +go 1.24.0 require ( github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 go.mongodb.org/mongo-driver v1.13.1 - golang.org/x/text v0.9.0 + golang.org/x/text v0.34.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -30,16 +30,22 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/excelize/v2 v2.10.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.9.0 // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect - golang.org/x/sys v0.8.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect google.golang.org/protobuf v1.30.0 // indirect ) diff --git a/server/go.sum b/server/go.sum index 2ab1518..f96eb27 100644 --- a/server/go.sum +++ b/server/go.sum @@ -54,6 +54,10 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -65,6 +69,9 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= @@ -75,6 +82,12 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 h1:K8gF0eekWPEX+57l30ixxzGhHH/qscI3JCnuhbN6V4M= github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= @@ -90,6 +103,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -97,9 +112,13 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -110,6 +129,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -120,6 +141,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/server/handler/export.go b/server/handler/export.go new file mode 100644 index 0000000..d21c2f8 --- /dev/null +++ b/server/handler/export.go @@ -0,0 +1,134 @@ +package handler + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/xuri/excelize/v2" + + "billai-server/repository" +) + +type ExportBillsRequest struct { + StartDate string `form:"start_date"` + EndDate string `form:"end_date"` + Category string `form:"category"` + Type string `form:"type"` + IncomeExpense string `form:"income_expense"` +} + +func ExportBills(c *gin.Context) { + var req ExportBillsRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "result": false, + "message": "参数解析失败: " + err.Error(), + }) + return + } + + filter := buildFilterFromRequest(req) + + repo := repository.GetRepository() + if repo == nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "result": false, + "message": "数据库未连接", + }) + return + } + + bills, err := repo.GetCleanedBills(filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "result": false, + "message": "查询失败: " + err.Error(), + }) + return + } + + f := excelize.NewFile() + sheet := "账单" + f.SetSheetName("Sheet1", sheet) + + headers := []string{"时间", "来源", "分类", "交易对方", "商品说明", "收/支", "金额", "支付方式", "状态", "备注"} + for i, header := range headers { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + f.SetCellValue(sheet, cell, header) + } + + for idx, bill := range bills { + row := idx + 2 + + f.SetCellValue(sheet, fmt.Sprintf("A%d", row), bill.Time.Time().Format("2006-01-02 15:04:05")) + f.SetCellValue(sheet, fmt.Sprintf("B%d", row), bill.BillType) + f.SetCellValue(sheet, fmt.Sprintf("C%d", row), bill.Category) + f.SetCellValue(sheet, fmt.Sprintf("D%d", row), bill.Merchant) + f.SetCellValue(sheet, fmt.Sprintf("E%d", row), bill.Description) + f.SetCellValue(sheet, fmt.Sprintf("F%d", row), bill.IncomeExpense) + f.SetCellValue(sheet, fmt.Sprintf("G%d", row), bill.Amount) + f.SetCellValue(sheet, fmt.Sprintf("H%d", row), bill.PayMethod) + f.SetCellValue(sheet, fmt.Sprintf("I%d", row), bill.Status) + f.SetCellValue(sheet, fmt.Sprintf("J%d", row), bill.Remark) + } + + f.SetColWidth(sheet, "A", "A", 20) + f.SetColWidth(sheet, "B", "B", 8) + f.SetColWidth(sheet, "C", "C", 12) + f.SetColWidth(sheet, "D", "D", 20) + f.SetColWidth(sheet, "E", "E", 30) + f.SetColWidth(sheet, "F", "F", 8) + f.SetColWidth(sheet, "G", "G", 12) + f.SetColWidth(sheet, "H", "H", 15) + f.SetColWidth(sheet, "I", "I", 10) + f.SetColWidth(sheet, "J", "J", 20) + + filename := fmt.Sprintf("bills_%s.xlsx", time.Now().Format("20060102_150405")) + c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + c.Header("Access-Control-Expose-Headers", "Content-Disposition") + + if err := f.Write(c.Writer); err != nil { + fmt.Printf("导出 Excel 失败: %v\n", err) + } +} + +func buildFilterFromRequest(req ExportBillsRequest) map[string]interface{} { + filter := make(map[string]interface{}) + + if req.StartDate != "" || req.EndDate != "" { + timeFilter := make(map[string]interface{}) + if req.StartDate != "" { + startTime, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local) + if err == nil { + timeFilter["$gte"] = startTime + } + } + if req.EndDate != "" { + endTime, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local) + if err == nil { + endTime = endTime.Add(24 * time.Hour) + timeFilter["$lt"] = endTime + } + } + if len(timeFilter) > 0 { + filter["time"] = timeFilter + } + } + + if req.Category != "" { + filter["category"] = req.Category + } + if req.Type != "" { + filter["bill_type"] = req.Type + } + if req.IncomeExpense != "" { + filter["income_expense"] = req.IncomeExpense + } + + filter["is_deleted"] = false + + return filter +} diff --git a/server/router/router.go b/server/router/router.go index 49b9f11..70f1518 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -59,6 +59,9 @@ func setupAPIRoutes(r *gin.Engine) { // 账单查询 authed.GET("/bills", handler.ListBills) + // 导出账单 + authed.GET("/bills/export", handler.ExportBills) + // 编辑账单 authed.POST("/bills/:id", handler.UpdateBill) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ecb88ca..fe10773 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -316,6 +316,46 @@ export async function fetchBills(params: FetchBillsParams = {}): Promise { + const searchParams = new URLSearchParams(); + + if (params.start_date) searchParams.set('start_date', params.start_date); + if (params.end_date) searchParams.set('end_date', params.end_date); + if (params.category) searchParams.set('category', params.category); + if (params.type) searchParams.set('type', params.type); + if (params.income_expense) searchParams.set('income_expense', params.income_expense); + + const queryString = searchParams.toString(); + const url = `${API_BASE}/api/bills/export${queryString ? '?' + queryString : ''}`; + + const response = await apiFetch(url); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const blob = await response.blob(); + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = `bills_${new Date().toISOString().slice(0, 10)}.xlsx`; + + if (contentDisposition) { + const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + if (match && match[1]) { + filename = match[1].replace(/['"]/g, ''); + } + } + + const objectUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(objectUrl); +} + // 手动输入账单数据 export interface ManualBillInput { time: string; diff --git a/web/src/routes/bills/+page.svelte b/web/src/routes/bills/+page.svelte index e400c2f..67acf36 100644 --- a/web/src/routes/bills/+page.svelte +++ b/web/src/routes/bills/+page.svelte @@ -15,8 +15,9 @@ import BillDetailDrawer from '$lib/components/analysis/BillDetailDrawer.svelte'; import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill'; import { categories } from '$lib/data/categories'; - import { formatLocalDate, formatDateTime } from '$lib/utils'; - import Loader2 from '@lucide/svelte/icons/loader-2'; +import { formatLocalDate, formatDateTime } from '$lib/utils'; +import { exportBills } from '$lib/api'; +import Loader2 from '@lucide/svelte/icons/loader-2'; import AlertCircle from '@lucide/svelte/icons/alert-circle'; import Search from '@lucide/svelte/icons/search'; import Receipt from '@lucide/svelte/icons/receipt'; @@ -26,9 +27,10 @@ import Filter from '@lucide/svelte/icons/filter'; import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; - import RefreshCw from '@lucide/svelte/icons/refresh-cw'; - import Plus from '@lucide/svelte/icons/plus'; - import List from '@lucide/svelte/icons/list'; +import RefreshCw from '@lucide/svelte/icons/refresh-cw'; +import Plus from '@lucide/svelte/icons/plus'; +import List from '@lucide/svelte/icons/list'; +import Download from '@lucide/svelte/icons/download'; // 状态 let isLoading = $state(false); @@ -221,6 +223,29 @@ totalIncome = Math.max(0, totalIncome - deleted.amount); } } + + // 导出 Excel + let isExporting = $state(false); + let exportError = $state(''); + + async function handleExport() { + isExporting = true; + exportError = ''; + + try { + await exportBills({ + start_date: startDate || undefined, + end_date: endDate || undefined, + category: filterCategory || undefined, + type: filterBillType || undefined, + income_expense: filterIncomeExpense || undefined, + }); + } catch (err) { + exportError = err instanceof Error ? err.message : '导出失败'; + } finally { + isExporting = false; + } + } @@ -240,6 +265,10 @@ 刷新 + {/if}