fix: 修复账单删除功能并支持分析页面删除操作
Some checks are pending
Deploy BillAI / Deploy to Production (push) Waiting to run
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 文档
This commit is contained in:
184
AGENTS.md
184
AGENTS.md
@@ -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)
|
|
||||||
|
|||||||
13
analyzer/.dockerignore
Normal file
13
analyzer/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.DS_Store
|
||||||
25
server/.dockerignore
Normal file
25
server/.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Ignore these files for docker build context
|
||||||
|
# Binaries
|
||||||
|
server
|
||||||
|
billai-server
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
|
||||||
|
# Dependencies (if any are local and not in go.mod/go.sum, unlikely for Go)
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Logs and outputs
|
||||||
|
*.log
|
||||||
|
outputs/
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# IDE config
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
@@ -14,7 +14,7 @@ type DeleteBillResponse struct {
|
|||||||
Message string `json:"message,omitempty"`
|
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 == "" {
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
5
web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 数组
|
||||||
@@ -47,6 +48,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);
|
||||||
let selectedDate = $state<Date | null>(null);
|
let selectedDate = $state<Date | null>(null);
|
||||||
@@ -923,6 +946,7 @@
|
|||||||
pageSize={8}
|
pageSize={8}
|
||||||
{categories}
|
{categories}
|
||||||
onUpdate={handleRecordUpdated}
|
onUpdate={handleRecordUpdated}
|
||||||
|
onDelete={handleRecordDeleted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -127,6 +127,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleBillDeleted(deleted: UIBill) {
|
||||||
|
const idx = records.findIndex(r =>
|
||||||
|
r.id === (deleted as unknown as { id?: string }).id ||
|
||||||
|
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||||
|
);
|
||||||
|
if (idx !== -1) {
|
||||||
|
records.splice(idx, 1);
|
||||||
|
records = [...records];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allIdx = allRecords.findIndex(r =>
|
||||||
|
r.id === (deleted as unknown as { id?: string }).id ||
|
||||||
|
(r.time === deleted.time && r.merchant === deleted.merchant && r.amount === deleted.amount)
|
||||||
|
);
|
||||||
|
if (allIdx !== -1) {
|
||||||
|
allRecords.splice(allIdx, 1);
|
||||||
|
allRecords = [...allRecords];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted.incomeExpense === '支出') {
|
||||||
|
backendTotalExpense = Math.max(0, backendTotalExpense - deleted.amount);
|
||||||
|
} else if (deleted.incomeExpense === '收入') {
|
||||||
|
backendTotalIncome = Math.max(0, backendTotalIncome - deleted.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 分类列表按数据中出现次数排序
|
// 分类列表按数据中出现次数排序
|
||||||
let sortedCategories = $derived(() => {
|
let sortedCategories = $derived(() => {
|
||||||
const categoryCounts = new Map<string, number>();
|
const categoryCounts = new Map<string, number>();
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user