feat: 新增账单导出 Excel 功能
- 后端新增 /api/bills/export 接口,支持当前筛选条件导出全部记录 - 使用 excelize 库生成 xlsx 格式文件 - 前端账单管理页面添加导出按钮 - 更新 Go 版本到 1.24 以支持 excelize 依赖
This commit is contained in:
212
AGENTS.md
212
AGENTS.md
@@ -3,208 +3,112 @@
|
|||||||
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 (Frontend Proxy & UI, port 3000)
|
- `web/` - SvelteKit 5 + TailwindCSS 4 + TypeScript (Frontend, port 3000)
|
||||||
- `server/` - Go 1.21 + Gin + MongoDB (Main API & Data Storage, port 8080)
|
- `server/` - Go 1.21 + Gin + MongoDB (API, port 8080)
|
||||||
- `analyzer/` - Python 3.12 + FastAPI (Data Cleaning & Analysis Service, port 8001)
|
- `analyzer/` - Python 3.12 + FastAPI (Data cleaning, port 8001)
|
||||||
|
|
||||||
The SvelteKit frontend acts as a **proxy**: all `/api/*` browser requests are forwarded by
|
SvelteKit proxies `/api/*` requests to Go backend via `web/src/routes/api/[...path]/+server.ts`.
|
||||||
`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).
|
|
||||||
|
|
||||||
## 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 Vite dev server
|
npm run dev # Start dev server
|
||||||
npm run build # Production build (adapter-node)
|
npm run build # Production build
|
||||||
npm run preview # Preview production build
|
npm run check # TypeScript check
|
||||||
npm run check # TypeScript check (svelte-check)
|
npm run lint # Prettier + ESLint
|
||||||
npm run lint # Prettier --check + ESLint
|
|
||||||
npm run format # Format with Prettier
|
npm run format # Format with Prettier
|
||||||
npm run test # Run all unit tests once (CI mode)
|
npm run test # Run all tests (CI mode)
|
||||||
npm run test:unit # Run unit tests in watch mode
|
npx vitest run src/xxx.spec.ts # Run single test file
|
||||||
npx vitest run src/routes/+page.spec.ts # Run single test file
|
npx vitest run -t "pattern" # Run by name pattern
|
||||||
npx vitest run -t "test name pattern" # Run tests 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 -o server . # Build binary
|
go build -o server . # Build binary
|
||||||
go mod tidy # Clean dependencies
|
|
||||||
go test ./... # Run all tests
|
go test ./... # Run all tests
|
||||||
go test ./handler/... # Run handler package tests
|
go test ./handler/... # Run handler tests
|
||||||
go test -run TestName ./... # Run single test function
|
go test -run TestName ./... # Run single test
|
||||||
go test -v ./handler/... # Verbose test output
|
go test -v ./handler/... # Verbose output
|
||||||
```
|
```
|
||||||
|
|
||||||
### Analyzer (analyzer/)
|
### Analyzer (analyzer/)
|
||||||
**Working Directory:** `/Users/clz/Projects/BillAI/analyzer`
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python server.py # Start FastAPI server directly
|
python server.py # Start FastAPI
|
||||||
uvicorn server:app --reload # Start with hot reload
|
uvicorn server:app --reload # Hot reload
|
||||||
pytest # Run all tests
|
pytest # Run all tests
|
||||||
pytest test_jd_cleaner.py # Run single test file
|
pytest test_jd_cleaner.py # Single test file
|
||||||
pytest -k "test_name" # Run test by name pattern
|
pytest -k "pattern" # Run by 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 services
|
docker-compose up -d --build # Start/rebuild all services
|
||||||
docker-compose logs -f server # Follow service logs
|
docker-compose logs -f server # Follow logs
|
||||||
docker-compose down # Stop all services
|
docker-compose down # Stop services
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
### General
|
### General
|
||||||
- **Comments:** Existing comments often use Chinese for business logic explanations. Maintain this
|
- **Comments:** Chinese common for business logic; English for technical.
|
||||||
style where appropriate; English is also acceptable for technical explanations.
|
- **Conventions:** Follow existing patterns. Check `package.json`/`go.mod`/`requirements.txt` before adding dependencies.
|
||||||
- **Conventions:** Follow existing patterns strictly. Do not introduce new frameworks or libraries
|
|
||||||
without checking `package.json` / `go.mod` / `requirements.txt`.
|
|
||||||
|
|
||||||
### TypeScript/Svelte (web/)
|
### TypeScript/Svelte (web/)
|
||||||
- **Formatting:** Prettier — tabs, single quotes, no trailing commas, printWidth 100,
|
- **Formatting:** Prettier (tabs, single quotes, printWidth 100)
|
||||||
`prettier-plugin-svelte`.
|
- **Naming:** `PascalCase` for types/components, `camelCase` for variables
|
||||||
- **Naming:** `PascalCase` for types/interfaces/components, `camelCase` for variables/functions.
|
- **Imports:** Use `$lib` alias, `$app/*` for SvelteKit builtins. No relative paths for lib modules.
|
||||||
- **Imports:** Use `$lib` alias for internal imports and `$app/*` for SvelteKit builtins. Never
|
- **Svelte 5:** Use runes (`$state`, `$derived`, `$effect`, `$props`). Event: `onclick={fn}`.
|
||||||
use relative paths for lib-level modules.
|
- **Types:** `export interface` for models. Frontend `camelCase`, API `snake_case`. Converters in `$lib/models/bill.ts`.
|
||||||
```typescript
|
- **Error Handling:** Check `response.ok`, throw `Error(\`HTTP ${status}\`)`. On 401: `auth.logout()` + redirect.
|
||||||
import { browser } from '$app/environment'
|
- **Auth:** `createAuthStore()` in `$lib/stores/auth.ts`. Token in `localStorage` key `auth`. Use `apiFetch()` in `$lib/api.ts`.
|
||||||
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 <token>` 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`.
|
|
||||||
|
|
||||||
### Go Backend (server/)
|
### Go Backend (server/)
|
||||||
- **Layer structure:** `handler` (HTTP) → `service` (logic) → `adapter` (external Python service)
|
- **Layer:** `handler` → `service` → `adapter`/`repository` → `model`. No business logic in handlers.
|
||||||
and `repository` (DB) → `model` (structs). Handlers must not contain business logic.
|
- **Struct tags:** JSON `snake_case`, `omitempty` optional. Pointer for optional patch fields. Sensitive: `json:"-"`.
|
||||||
- **Struct tags:** JSON uses `snake_case`. `omitempty` on optional response fields. Use `form` tags
|
- **Error handling:** 500 for DB errors, 400 for bad requests, 404 not found. Wrap with `fmt.Errorf("context: %w", err)`.
|
||||||
for query/form binding. Use pointer fields (`*string`) for optional patch request fields. Sensitive
|
- **Response:** `Result bool`, `Message`, `Data *T`. Auth: `success bool`, `error`, `data`.
|
||||||
fields get `json:"-"`.
|
- **Time:** Use custom `LocalTime` type (serializes as `2006-01-02 15:04:05`).
|
||||||
```go
|
- **Soft delete:** Never hard-delete. Filter `is_deleted: false` in queries.
|
||||||
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`.
|
|
||||||
|
|
||||||
### Python Analyzer (analyzer/)
|
### Python Analyzer (analyzer/)
|
||||||
- **Style:** PEP 8. `snake_case` for variables, functions, and filenames. `UPPER_CASE` for
|
- **Style:** PEP 8. `snake_case` variables, `UPPER_CASE` constants. Prefix private globals with `_`.
|
||||||
module-level constants. Prefix private module globals with `_`.
|
- **Type hints:** Mandatory. Use `Optional[str]` or `str | None`.
|
||||||
- **Type Hints:** Mandatory for all function arguments and return types. Use `Optional[str]` from
|
- **Models:** `pydantic.BaseModel` for API schemas.
|
||||||
`typing` or `str | None` (Python 3.10+ union syntax).
|
- **Cleaners:** Extend `BaseCleaner(ABC)` from `cleaners/base.py`. Category rules in `config/category.yaml`.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
### API Flow
|
### API Flow
|
||||||
```
|
```
|
||||||
Browser → SvelteKit proxy (/api/[...path]/+server.ts)
|
Browser → SvelteKit proxy → Go (Gin) → handler → service → adapter → Python FastAPI
|
||||||
→ Go server (Gin, AuthRequired middleware)
|
└→ repository → MongoDB
|
||||||
→ handler → service → adapter.GetCleaner() → HTTP POST to Python FastAPI
|
|
||||||
→ repository.GetRepository() → MongoDB
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
- JWT (HS256). Token in `localStorage` under key `auth`.
|
- JWT (HS256). Token in `localStorage` key `auth`. Header: `Authorization: Bearer <token>`.
|
||||||
- Header: `Authorization: Bearer <token>`.
|
- `middleware.AuthRequired()` wraps `/api/*` (except `/api/auth/*`).
|
||||||
- `middleware.AuthRequired()` wraps all `/api/*` routes except `/api/auth/*`.
|
- 401 anywhere → `auth.logout()` + redirect `/login`.
|
||||||
- Passwords in `config.yaml` support plaintext or SHA-256 hashed values.
|
|
||||||
- 401 anywhere → `auth.logout()` + redirect to `/login`.
|
|
||||||
|
|
||||||
### File Processing
|
### File Processing
|
||||||
Upload flow: Upload (ZIP/XLSX) → Extract → Convert to UTF-8 CSV (Python `/convert`) →
|
Upload: ZIP/XLSX → Extract → Convert UTF-8 CSV → Detect bill type → Deduplicate → Clean → Save to MongoDB.
|
||||||
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.
|
|
||||||
|
|
||||||
### Adapter (Go ↔ Python)
|
### Adapter (Go ↔ Python)
|
||||||
`adapter.Cleaner` interface has two implementations: HTTP-based (`adapter/http`, default) and
|
`adapter.Cleaner` interface: HTTP (`adapter/http`, default) or subprocess (`adapter/python`). Set via `ANALYZER_MODE` env var.
|
||||||
subprocess-based (`adapter/python`, legacy). Controlled by `ANALYZER_MODE` env var.
|
|
||||||
|
|
||||||
## Important Files
|
## Important Files
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `web/src/lib/api.ts` | Central API client; `apiFetch()` injects auth and handles 401 |
|
| `web/src/lib/api.ts` | Central API client, auth injection |
|
||||||
| `web/src/lib/stores/auth.ts` | Auth state; JWT in localStorage; login/logout/validate |
|
| `web/src/lib/stores/auth.ts` | Auth state, JWT handling |
|
||||||
| `web/src/lib/models/bill.ts` | `UIBill` model + converters to/from API `CleanedBill` shape |
|
| `web/src/lib/models/bill.ts` | UIBill model + converters |
|
||||||
| `web/src/routes/api/[...path]/+server.ts` | SvelteKit proxy to Go backend |
|
| `server/main.go` | Entry point |
|
||||||
| `server/main.go` | Entry point; wires config, adapters, repository, router |
|
| `server/handler/upload.go` | Full upload pipeline |
|
||||||
| `server/config/config.go` | YAML + env config; priority: defaults → config.yaml → env vars |
|
| `server/handler/bills.go` | List/filter bills |
|
||||||
| `server/router/router.go` | All route definitions and middleware assignment |
|
| `server/model/bill.go` | Bill models, LocalTime type |
|
||||||
| `server/middleware/auth.go` | JWT validation + user context injection |
|
| `server/adapter/adapter.go` | Cleaner interface |
|
||||||
| `server/handler/upload.go` | Full upload pipeline (extract → convert → clean → store) |
|
| `server/repository/mongo/repository.go` | MongoDB implementation |
|
||||||
| `server/handler/bills.go` | List/filter bills with pagination and monthly stats |
|
| `analyzer/server.py` | FastAPI entry |
|
||||||
| `server/model/bill.go` | `RawBill`, `CleanedBill`, `MonthlyStat`; custom `LocalTime` type |
|
| `analyzer/cleaners/base.py` | BaseCleaner ABC |
|
||||||
| `server/adapter/adapter.go` | `Cleaner` interface definition |
|
| `analyzer/category.py` | Category inference |
|
||||||
| `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 |
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# 多阶段构建:编译阶段 + 运行阶段
|
# 多阶段构建:编译阶段 + 运行阶段
|
||||||
|
|
||||||
# ===== 编译阶段 =====
|
# ===== 编译阶段 =====
|
||||||
FROM golang:1.21-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# BillAI 服务器配置文件
|
# BillAI 服务器配置文件
|
||||||
|
|
||||||
# 应用版本
|
# 应用版本
|
||||||
version: "1.0.7"
|
version: "1.0.8"
|
||||||
|
|
||||||
# 服务配置
|
# 服务配置
|
||||||
server:
|
server:
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
module billai-server
|
module billai-server
|
||||||
|
|
||||||
go 1.21
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9
|
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9
|
||||||
go.mongodb.org/mongo-driver v1.13.1
|
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
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,16 +30,22 @@ require (
|
|||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.1.2 // indirect
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // 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
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/crypto v0.9.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.10.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.8.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
google.golang.org/protobuf v1.30.0 // indirect
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
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.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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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=
|
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/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 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
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 h1:K8gF0eekWPEX+57l30ixxzGhHH/qscI3JCnuhbN6V4M=
|
||||||
github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI=
|
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=
|
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.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 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
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/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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
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.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 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
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-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 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
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.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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
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=
|
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.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 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
134
server/handler/export.go
Normal file
134
server/handler/export.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -59,6 +59,9 @@ func setupAPIRoutes(r *gin.Engine) {
|
|||||||
// 账单查询
|
// 账单查询
|
||||||
authed.GET("/bills", handler.ListBills)
|
authed.GET("/bills", handler.ListBills)
|
||||||
|
|
||||||
|
// 导出账单
|
||||||
|
authed.GET("/bills/export", handler.ExportBills)
|
||||||
|
|
||||||
// 编辑账单
|
// 编辑账单
|
||||||
authed.POST("/bills/:id", handler.UpdateBill)
|
authed.POST("/bills/:id", handler.UpdateBill)
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,46 @@ export async function fetchBills(params: FetchBillsParams = {}): Promise<BillsRe
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出账单为 Excel
|
||||||
|
export async function exportBills(params: FetchBillsParams = {}): Promise<void> {
|
||||||
|
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 {
|
export interface ManualBillInput {
|
||||||
time: string;
|
time: string;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||||
import { categories } from '$lib/data/categories';
|
import { categories } from '$lib/data/categories';
|
||||||
import { formatLocalDate, formatDateTime } from '$lib/utils';
|
import { formatLocalDate, formatDateTime } from '$lib/utils';
|
||||||
|
import { exportBills } from '$lib/api';
|
||||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import List from '@lucide/svelte/icons/list';
|
import List from '@lucide/svelte/icons/list';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
@@ -221,6 +223,29 @@
|
|||||||
totalIncome = Math.max(0, totalIncome - deleted.amount);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -240,6 +265,10 @@
|
|||||||
<RefreshCw class="mr-2 h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
|
<RefreshCw class="mr-2 h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" onclick={handleExport} disabled={isExporting || totalRecords === 0}>
|
||||||
|
<Download class="mr-2 h-4 w-4 {isExporting ? 'animate-spin' : ''}" />
|
||||||
|
导出 Excel
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user