Compare commits
4 Commits
339b8afe98
...
871da2454c
| Author | SHA1 | Date | |
|---|---|---|---|
| 871da2454c | |||
| 65ea2fa477 | |||
| c61691249f | |||
| f5afb0c135 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -5,6 +5,19 @@
|
|||||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||||
|
|
||||||
|
## [1.0.8] - 2026-01-18
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- **前端账单模型统一为 UIBill** - 分析链路与详情弹窗只使用一套 UI 模型(camelCase + amount:number),移除 BillRecord 混用带来的字段/类型转换散落
|
||||||
|
- 分析页、统计服务与各分析组件统一使用 `UIBill[]`
|
||||||
|
- CSV 解析(下载账单内容)直接输出 `UIBill[]`
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **账单详情弹窗抽象组件** - 新增 `BillDetailDrawer`,复用单笔账单的查看/编辑 UI 结构
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
- **前端检查更干净** - 修复图表容器的派生值捕获告警,并为趋势图增加键盘可访问性,`npm run check` 达到 0 warnings
|
||||||
|
|
||||||
## [1.0.7] - 2026-01-16
|
## [1.0.7] - 2026-01-16
|
||||||
|
|
||||||
### 优化
|
### 优化
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。
|
一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -10,6 +10,8 @@
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
变更记录见 [CHANGELOG.md](CHANGELOG.md)。
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
- 📊 **账单分析** - 自动解析微信/支付宝账单,生成可视化报表
|
- 📊 **账单分析** - 自动解析微信/支付宝账单,生成可视化报表
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ services:
|
|||||||
# 挂载 Docker Socket 以支持容器操作
|
# 挂载 Docker Socket 以支持容器操作
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
# 挂载仓库目录
|
# 挂载仓库目录
|
||||||
- /path/to/billai:/app
|
- ./:/app
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:9000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:9000/health"]
|
||||||
|
|||||||
145
server/handler/update_bill.go
Normal file
145
server/handler/update_bill.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"billai-server/model"
|
||||||
|
"billai-server/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateBillRequest 账单更新请求(字段均为可选)
|
||||||
|
type UpdateBillRequest struct {
|
||||||
|
Time *string `json:"time,omitempty"`
|
||||||
|
Category *string `json:"category,omitempty"`
|
||||||
|
Merchant *string `json:"merchant,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
IncomeExpense *string `json:"income_expense,omitempty"`
|
||||||
|
Amount *float64 `json:"amount,omitempty"`
|
||||||
|
PayMethod *string `json:"pay_method,omitempty"`
|
||||||
|
Status *string `json:"status,omitempty"`
|
||||||
|
Remark *string `json:"remark,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateBillResponse struct {
|
||||||
|
Result bool `json:"result"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Data *model.CleanedBill `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBillTime(s string) (time.Time, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
formats := []string{
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04:05Z07:00",
|
||||||
|
"2006-01-02T15:04:05Z",
|
||||||
|
"2006-01-02",
|
||||||
|
}
|
||||||
|
for _, f := range formats {
|
||||||
|
if t, err := time.ParseInLocation(f, s, time.Local); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("unsupported time format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBill PATCH /api/bills/:id 更新清洗后的账单记录
|
||||||
|
func UpdateBill(c *gin.Context) {
|
||||||
|
id := strings.TrimSpace(c.Param("id"))
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "缺少账单 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateBillRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "参数解析失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
|
||||||
|
if req.Time != nil {
|
||||||
|
t, err := parseBillTime(*req.Time)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "时间格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["time"] = t
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Category != nil {
|
||||||
|
v := strings.TrimSpace(*req.Category)
|
||||||
|
if v == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "分类不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["category"] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Merchant != nil {
|
||||||
|
v := strings.TrimSpace(*req.Merchant)
|
||||||
|
if v == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "商家不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["merchant"] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Description != nil {
|
||||||
|
updates["description"] = strings.TrimSpace(*req.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.IncomeExpense != nil {
|
||||||
|
v := strings.TrimSpace(*req.IncomeExpense)
|
||||||
|
if v != "" && v != "收入" && v != "支出" {
|
||||||
|
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "income_expense 只能是 收入 或 支出"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["income_expense"] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Amount != nil {
|
||||||
|
updates["amount"] = *req.Amount
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PayMethod != nil {
|
||||||
|
updates["pay_method"] = strings.TrimSpace(*req.PayMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status != nil {
|
||||||
|
updates["status"] = strings.TrimSpace(*req.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Remark != nil {
|
||||||
|
updates["remark"] = strings.TrimSpace(*req.Remark)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "没有可更新的字段"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["updated_at"] = time.Now()
|
||||||
|
|
||||||
|
repo := repository.GetRepository()
|
||||||
|
if repo == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, UpdateBillResponse{Result: false, Message: "数据库未连接"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := repo.UpdateCleanedBillByID(id, updates)
|
||||||
|
if err != nil {
|
||||||
|
if err == repository.ErrNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, UpdateBillResponse{Result: false, Message: "账单不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, UpdateBillResponse{Result: false, Message: "更新失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, UpdateBillResponse{Result: true, Message: "更新成功", Data: updated})
|
||||||
|
}
|
||||||
6
server/repository/errors.go
Normal file
6
server/repository/errors.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrNotFound 表示目标记录不存在
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/bson"
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
"go.mongodb.org/mongo-driver/mongo/options"
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
|
||||||
@@ -383,6 +384,40 @@ func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) {
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateCleanedBillByID 按 ID 更新清洗后的账单,并返回更新后的记录
|
||||||
|
func (r *Repository) UpdateCleanedBillByID(id string, updates map[string]interface{}) (*model.CleanedBill, error) {
|
||||||
|
if r.cleanedCollection == nil {
|
||||||
|
return nil, fmt.Errorf("cleaned collection not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
oid, err := primitive.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil, fmt.Errorf("no updates")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
filter := bson.M{"_id": oid}
|
||||||
|
update := bson.M{"$set": updates}
|
||||||
|
opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
|
||||||
|
|
||||||
|
var updated model.CleanedBill
|
||||||
|
err = r.cleanedCollection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&updated)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, repository.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("update bill failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
||||||
func (r *Repository) GetClient() *mongo.Client {
|
func (r *Repository) GetClient() *mongo.Client {
|
||||||
return r.client
|
return r.client
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ type BillRepository interface {
|
|||||||
// GetBillsNeedReview 获取需要复核的账单
|
// GetBillsNeedReview 获取需要复核的账单
|
||||||
GetBillsNeedReview() ([]model.CleanedBill, error)
|
GetBillsNeedReview() ([]model.CleanedBill, error)
|
||||||
|
|
||||||
|
// UpdateCleanedBillByID 按 ID 更新清洗后的账单,并返回更新后的记录
|
||||||
|
UpdateCleanedBillByID(id string, updates map[string]interface{}) (*model.CleanedBill, error)
|
||||||
|
|
||||||
// CountRawByField 按字段统计原始数据数量
|
// CountRawByField 按字段统计原始数据数量
|
||||||
CountRawByField(fieldName, value string) (int64, error)
|
CountRawByField(fieldName, value string) (int64, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ func setupAPIRoutes(r *gin.Engine) {
|
|||||||
// 账单查询
|
// 账单查询
|
||||||
authed.GET("/bills", handler.ListBills)
|
authed.GET("/bills", handler.ListBills)
|
||||||
|
|
||||||
|
// 编辑账单
|
||||||
|
authed.PATCH("/bills/:id", handler.UpdateBill)
|
||||||
|
|
||||||
// 手动创建账单
|
// 手动创建账单
|
||||||
authed.POST("/bills/manual", handler.CreateManualBills)
|
authed.POST("/bills/manual", handler.CreateManualBills)
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
# sv
|
# BillAI Web
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
BillAI 的前端 Web 应用,基于 SvelteKit + Tailwind,提供账单分析/复核/管理等界面。
|
||||||
|
|
||||||
## Creating a project
|
## 开发
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# create a new project in the current directory
|
yarn install
|
||||||
npx sv create
|
yarn dev
|
||||||
|
|
||||||
# create a new project in my-app
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
常用命令:
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev
|
yarn check
|
||||||
|
yarn lint
|
||||||
# or start the server and open the app in a new browser tab
|
yarn format
|
||||||
npm run dev -- --open
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
## 构建
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run build
|
yarn build
|
||||||
|
yarn preview
|
||||||
```
|
```
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
## API 访问
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
- 开发环境下通过 SvelteKit/Vite 代理访问后端(统一使用相对路径,例如 `/api/...`)
|
||||||
|
- Docker 部署时由 `docker-compose.yaml` 将前端容器与后端容器联通
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
前端展示层使用统一账单模型 `UIBill`(camelCase 字段 + `amount:number`),分析链路与详情编辑弹窗避免多种账单类型混用。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.7",
|
"version": "1.0.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { auth } from '$lib/stores/auth';
|
import { auth } from '$lib/stores/auth';
|
||||||
|
import type { UIBill } from '$lib/models/bill';
|
||||||
|
|
||||||
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端
|
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端
|
||||||
const API_BASE = '';
|
const API_BASE = '';
|
||||||
@@ -95,19 +96,6 @@ export interface MonthlyStatsResponse {
|
|||||||
data?: MonthlyStat[];
|
data?: MonthlyStat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BillRecord {
|
|
||||||
time: string;
|
|
||||||
category: string;
|
|
||||||
merchant: string;
|
|
||||||
description: string;
|
|
||||||
income_expense: string;
|
|
||||||
amount: string;
|
|
||||||
payment_method: string;
|
|
||||||
status: string;
|
|
||||||
remark: string;
|
|
||||||
needs_review: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传账单
|
// 上传账单
|
||||||
export async function uploadBill(
|
export async function uploadBill(
|
||||||
file: File,
|
file: File,
|
||||||
@@ -165,7 +153,7 @@ export function getDownloadUrl(fileUrl: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 解析账单内容(用于前端展示全部记录)
|
// 解析账单内容(用于前端展示全部记录)
|
||||||
export async function fetchBillContent(fileName: string): Promise<BillRecord[]> {
|
export async function fetchBillContent(fileName: string): Promise<UIBill[]> {
|
||||||
const response = await apiFetch(`${API_BASE}/download/${fileName}`);
|
const response = await apiFetch(`${API_BASE}/download/${fileName}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -177,11 +165,11 @@ export async function fetchBillContent(fileName: string): Promise<BillRecord[]>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 解析 CSV
|
// 解析 CSV
|
||||||
function parseCSV(text: string): BillRecord[] {
|
function parseCSV(text: string): UIBill[] {
|
||||||
const lines = text.trim().split('\n');
|
const lines = text.trim().split('\n');
|
||||||
if (lines.length < 2) return [];
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
const records: BillRecord[] = [];
|
const records: UIBill[] = [];
|
||||||
|
|
||||||
// CSV 格式:交易时间,交易分类,交易对方,对方账号,商品说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注,,复核等级
|
// CSV 格式:交易时间,交易分类,交易对方,对方账号,商品说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注,,复核等级
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
@@ -191,13 +179,13 @@ function parseCSV(text: string): BillRecord[] {
|
|||||||
time: values[0] || '',
|
time: values[0] || '',
|
||||||
category: values[1] || '',
|
category: values[1] || '',
|
||||||
merchant: values[2] || '',
|
merchant: values[2] || '',
|
||||||
description: values[4] || '', // 跳过 values[3] (对方账号)
|
description: values[4] || '', // 跳过 values[3] (对方账号)
|
||||||
income_expense: values[5] || '',
|
incomeExpense: values[5] || '',
|
||||||
amount: values[6] || '',
|
amount: Number(values[6] || 0),
|
||||||
payment_method: values[7] || '',
|
paymentMethod: values[7] || '',
|
||||||
status: values[8] || '',
|
status: values[8] || '',
|
||||||
remark: values[11] || '',
|
remark: values[11] || '',
|
||||||
needs_review: values[13] || '', // 复核等级在第14列
|
reviewLevel: values[13] || '', // 复核等级在第14列
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,6 +237,42 @@ export interface CleanedBill {
|
|||||||
review_level: string;
|
review_level: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新账单
|
||||||
|
export interface UpdateBillRequest {
|
||||||
|
time?: string;
|
||||||
|
category?: string;
|
||||||
|
merchant?: string;
|
||||||
|
description?: string;
|
||||||
|
income_expense?: string;
|
||||||
|
amount?: number;
|
||||||
|
pay_method?: string;
|
||||||
|
status?: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateBillResponse {
|
||||||
|
result: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: CleanedBill;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBill(id: string, patch: UpdateBillRequest): Promise<UpdateBillResponse> {
|
||||||
|
const response = await apiFetch(`${API_BASE}/api/bills/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// keep same behavior as other API calls
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// 账单列表请求参数
|
// 账单列表请求参数
|
||||||
export interface FetchBillsParams {
|
export interface FetchBillsParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|||||||
277
web/src/lib/components/analysis/BillDetailDrawer.svelte
Normal file
277
web/src/lib/components/analysis/BillDetailDrawer.svelte
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
import * as Drawer from '$lib/components/ui/drawer';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
|
import Receipt from '@lucide/svelte/icons/receipt';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Save from '@lucide/svelte/icons/save';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import Calendar from '@lucide/svelte/icons/calendar';
|
||||||
|
import Store from '@lucide/svelte/icons/store';
|
||||||
|
import Tag from '@lucide/svelte/icons/tag';
|
||||||
|
import FileText from '@lucide/svelte/icons/file-text';
|
||||||
|
import CreditCard from '@lucide/svelte/icons/credit-card';
|
||||||
|
|
||||||
|
import { updateBill } from '$lib/api';
|
||||||
|
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean;
|
||||||
|
record?: UIBill | null;
|
||||||
|
categories?: string[];
|
||||||
|
|
||||||
|
title?: string;
|
||||||
|
viewDescription?: string;
|
||||||
|
editDescription?: string;
|
||||||
|
|
||||||
|
titleExtra?: Snippet<[{ isEditing: boolean }]>;
|
||||||
|
|
||||||
|
contentClass?: string;
|
||||||
|
|
||||||
|
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
record = $bindable<UIBill | null>(null),
|
||||||
|
categories = [],
|
||||||
|
title = '账单详情',
|
||||||
|
viewDescription = '查看这笔支出的完整信息',
|
||||||
|
editDescription = '修改这笔支出的信息',
|
||||||
|
titleExtra,
|
||||||
|
contentClass,
|
||||||
|
onUpdate
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isEditing = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
|
||||||
|
let editForm = $state({
|
||||||
|
amount: '',
|
||||||
|
merchant: '',
|
||||||
|
category: '',
|
||||||
|
description: '',
|
||||||
|
payment_method: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
isEditing = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
if (!record) return;
|
||||||
|
editForm = {
|
||||||
|
amount: String(record.amount),
|
||||||
|
merchant: record.merchant,
|
||||||
|
category: record.category,
|
||||||
|
description: record.description || '',
|
||||||
|
payment_method: record.paymentMethod || ''
|
||||||
|
};
|
||||||
|
isEditing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
isEditing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCategoryChange(value: string | undefined) {
|
||||||
|
if (value) editForm.category = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!record) return;
|
||||||
|
if (isSaving) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
const original = { ...record };
|
||||||
|
|
||||||
|
const updated: UIBill = {
|
||||||
|
...record,
|
||||||
|
amount: Number(editForm.amount),
|
||||||
|
merchant: editForm.merchant,
|
||||||
|
category: editForm.category,
|
||||||
|
description: editForm.description,
|
||||||
|
paymentMethod: editForm.payment_method
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const billId = (record as unknown as { id?: string }).id;
|
||||||
|
if (billId) {
|
||||||
|
const resp = await updateBill(billId, {
|
||||||
|
merchant: editForm.merchant,
|
||||||
|
category: editForm.category,
|
||||||
|
amount: Number(editForm.amount),
|
||||||
|
description: editForm.description,
|
||||||
|
pay_method: editForm.payment_method
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.result && resp.data) {
|
||||||
|
const persisted = cleanedBillToUIBill(resp.data);
|
||||||
|
updated.id = persisted.id;
|
||||||
|
updated.amount = persisted.amount;
|
||||||
|
updated.merchant = persisted.merchant;
|
||||||
|
updated.category = persisted.category;
|
||||||
|
updated.description = persisted.description;
|
||||||
|
updated.paymentMethod = persisted.paymentMethod;
|
||||||
|
updated.time = persisted.time;
|
||||||
|
updated.incomeExpense = persisted.incomeExpense;
|
||||||
|
updated.status = persisted.status;
|
||||||
|
updated.remark = persisted.remark;
|
||||||
|
updated.reviewLevel = persisted.reviewLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record = updated;
|
||||||
|
isEditing = false;
|
||||||
|
onUpdate?.(updated, original);
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Drawer.Root bind:open>
|
||||||
|
<Drawer.Content class={`md:max-w-4xl ${contentClass ?? ''}`.trim()}>
|
||||||
|
<Drawer.Header>
|
||||||
|
<Drawer.Title class="flex items-center gap-2">
|
||||||
|
<Receipt class="h-5 w-5" />
|
||||||
|
{isEditing ? '编辑账单' : title}
|
||||||
|
{@render titleExtra?.({ isEditing })}
|
||||||
|
</Drawer.Title>
|
||||||
|
<Drawer.Description>
|
||||||
|
{isEditing ? editDescription : viewDescription}
|
||||||
|
</Drawer.Description>
|
||||||
|
</Drawer.Header>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-auto px-4 py-4 md:px-0">
|
||||||
|
{#if record}
|
||||||
|
{#if isEditing}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>金额</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
|
||||||
|
<Input type="number" bind:value={editForm.amount} class="pl-8" step="0.01" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>商家</Label>
|
||||||
|
<Input bind:value={editForm.merchant} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>分类</Label>
|
||||||
|
{#if categories.length > 0}
|
||||||
|
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
|
||||||
|
<Select.Trigger class="w-full">
|
||||||
|
<span>{editForm.category || '选择分类'}</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content>
|
||||||
|
{#each categories as category}
|
||||||
|
<Select.Item value={category}>{category}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select.Root>
|
||||||
|
{:else}
|
||||||
|
<Input bind:value={editForm.category} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>描述</Label>
|
||||||
|
<Input bind:value={editForm.description} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>支付方式</Label>
|
||||||
|
<Input bind:value={editForm.payment_method} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">¥{record.amount.toFixed(2)}</div>
|
||||||
|
<div class="text-sm text-muted-foreground mt-1">支出金额</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Store class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs text-muted-foreground">商家</div>
|
||||||
|
<div class="font-medium truncate">{record.merchant}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Tag class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs text-muted-foreground">分类</div>
|
||||||
|
<div class="font-medium">{record.category}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Calendar class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs text-muted-foreground">时间</div>
|
||||||
|
<div class="font-medium">{record.time}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if record.description}
|
||||||
|
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||||
|
<FileText class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs text-muted-foreground">描述</div>
|
||||||
|
<div class="font-medium">{record.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if record.paymentMethod}
|
||||||
|
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||||
|
<CreditCard class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs text-muted-foreground">支付方式</div>
|
||||||
|
<div class="font-medium">{record.paymentMethod}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Drawer.Footer>
|
||||||
|
{#if isEditing}
|
||||||
|
<Button variant="outline" onclick={cancelEdit}>
|
||||||
|
<X class="h-4 w-4 mr-2" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onclick={saveEdit} disabled={isSaving}>
|
||||||
|
<Save class="h-4 w-4 mr-2" />
|
||||||
|
{isSaving ? '保存中…' : '保存'}
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button variant="outline" onclick={() => (open = false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button onclick={startEdit}>
|
||||||
|
<Pencil class="h-4 w-4 mr-2" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</Drawer.Footer>
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer.Root>
|
||||||
@@ -1,33 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import * as Drawer from '$lib/components/ui/drawer';
|
|
||||||
import * as Select from '$lib/components/ui/select';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
||||||
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
||||||
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
import Receipt from '@lucide/svelte/icons/receipt';
|
import { type UIBill } from '$lib/models/bill';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import BillDetailDrawer from './BillDetailDrawer.svelte';
|
||||||
import Save from '@lucide/svelte/icons/save';
|
|
||||||
import X from '@lucide/svelte/icons/x';
|
|
||||||
import Calendar from '@lucide/svelte/icons/calendar';
|
|
||||||
import Store from '@lucide/svelte/icons/store';
|
|
||||||
import Tag from '@lucide/svelte/icons/tag';
|
|
||||||
import FileText from '@lucide/svelte/icons/file-text';
|
|
||||||
import CreditCard from '@lucide/svelte/icons/credit-card';
|
|
||||||
import type { BillRecord } from '$lib/api';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
records: BillRecord[];
|
records: UIBill[];
|
||||||
showCategory?: boolean;
|
showCategory?: boolean;
|
||||||
showDescription?: boolean;
|
showDescription?: boolean;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
onUpdate?: (updated: BillRecord, original: BillRecord) => void;
|
onUpdate?: (updated: UIBill, original: UIBill) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -50,16 +38,7 @@
|
|||||||
|
|
||||||
// 详情弹窗状态
|
// 详情弹窗状态
|
||||||
let detailDialogOpen = $state(false);
|
let detailDialogOpen = $state(false);
|
||||||
let selectedRecord = $state<BillRecord | null>(null);
|
let selectedRecord = $state<UIBill | null>(null);
|
||||||
let selectedIndex = $state(-1);
|
|
||||||
let isEditing = $state(false);
|
|
||||||
let editForm = $state({
|
|
||||||
amount: '',
|
|
||||||
merchant: '',
|
|
||||||
category: '',
|
|
||||||
description: '',
|
|
||||||
payment_method: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
// 排序后的记录
|
// 排序后的记录
|
||||||
let sortedRecords = $derived.by(() => {
|
let sortedRecords = $derived.by(() => {
|
||||||
@@ -79,7 +58,7 @@
|
|||||||
cmp = (a.description || '').localeCompare(b.description || '');
|
cmp = (a.description || '').localeCompare(b.description || '');
|
||||||
break;
|
break;
|
||||||
case 'amount':
|
case 'amount':
|
||||||
cmp = parseFloat(a.amount) - parseFloat(b.amount);
|
cmp = (a.amount || 0) - (b.amount || 0);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return sortOrder === 'asc' ? cmp : -cmp;
|
return sortOrder === 'asc' ? cmp : -cmp;
|
||||||
@@ -109,70 +88,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 打开详情弹窗
|
// 打开详情弹窗
|
||||||
function openDetail(record: BillRecord, index: number) {
|
function openDetail(record: UIBill) {
|
||||||
selectedRecord = record;
|
selectedRecord = record;
|
||||||
selectedIndex = index;
|
|
||||||
isEditing = false;
|
|
||||||
detailDialogOpen = true;
|
detailDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进入编辑模式
|
function handleRecordUpdated(updated: UIBill, original: UIBill) {
|
||||||
function startEdit() {
|
// 更新本地数据(fallback:按引用/关键字段查找)
|
||||||
if (!selectedRecord) return;
|
const idx = records.findIndex(r => r === original);
|
||||||
editForm = {
|
const finalIdx = idx !== -1
|
||||||
amount: selectedRecord.amount,
|
? idx
|
||||||
merchant: selectedRecord.merchant,
|
: records.findIndex(r =>
|
||||||
category: selectedRecord.category,
|
r.time === original.time &&
|
||||||
description: selectedRecord.description || '',
|
r.merchant === original.merchant &&
|
||||||
payment_method: selectedRecord.payment_method || ''
|
r.amount === original.amount
|
||||||
};
|
);
|
||||||
isEditing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消编辑
|
if (finalIdx !== -1) {
|
||||||
function cancelEdit() {
|
records[finalIdx] = updated;
|
||||||
isEditing = false;
|
records = [...records];
|
||||||
}
|
|
||||||
|
|
||||||
// 保存编辑
|
|
||||||
function saveEdit() {
|
|
||||||
if (!selectedRecord) return;
|
|
||||||
|
|
||||||
const original = { ...selectedRecord };
|
|
||||||
const updated: BillRecord = {
|
|
||||||
...selectedRecord,
|
|
||||||
amount: editForm.amount,
|
|
||||||
merchant: editForm.merchant,
|
|
||||||
category: editForm.category,
|
|
||||||
description: editForm.description,
|
|
||||||
payment_method: editForm.payment_method
|
|
||||||
};
|
|
||||||
|
|
||||||
// 更新本地数据
|
|
||||||
const idx = records.findIndex(r =>
|
|
||||||
r.time === selectedRecord!.time &&
|
|
||||||
r.merchant === selectedRecord!.merchant &&
|
|
||||||
r.amount === selectedRecord!.amount
|
|
||||||
);
|
|
||||||
if (idx !== -1) {
|
|
||||||
records[idx] = updated;
|
|
||||||
records = [...records]; // 触发响应式更新
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedRecord = updated;
|
|
||||||
isEditing = false;
|
|
||||||
|
|
||||||
// 通知父组件
|
|
||||||
onUpdate?.(updated, original);
|
onUpdate?.(updated, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理分类选择
|
|
||||||
function handleCategoryChange(value: string | undefined) {
|
|
||||||
if (value) {
|
|
||||||
editForm.category = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置分页(当记录变化时)
|
// 重置分页(当记录变化时)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
records;
|
records;
|
||||||
@@ -261,7 +200,7 @@
|
|||||||
{#each paginatedRecords as record, i}
|
{#each paginatedRecords as record, i}
|
||||||
<Table.Row
|
<Table.Row
|
||||||
class="hover:bg-muted/50 transition-colors cursor-pointer"
|
class="hover:bg-muted/50 transition-colors cursor-pointer"
|
||||||
onclick={() => openDetail(record, (currentPage - 1) * pageSize + i)}
|
onclick={() => openDetail(record)}
|
||||||
>
|
>
|
||||||
<Table.Cell class="text-muted-foreground text-xs">
|
<Table.Cell class="text-muted-foreground text-xs">
|
||||||
{record.time.substring(0, 16)}
|
{record.time.substring(0, 16)}
|
||||||
@@ -276,7 +215,7 @@
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{/if}
|
{/if}
|
||||||
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
|
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
|
||||||
¥{record.amount}
|
¥{record.amount.toFixed(2)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -333,151 +272,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- 详情/编辑弹窗 -->
|
<BillDetailDrawer
|
||||||
<Drawer.Root bind:open={detailDialogOpen}>
|
bind:open={detailDialogOpen}
|
||||||
<Drawer.Content class="sm:max-w-md">
|
bind:record={selectedRecord}
|
||||||
<Drawer.Header>
|
{categories}
|
||||||
<Drawer.Title class="flex items-center gap-2">
|
title="账单详情"
|
||||||
<Receipt class="h-5 w-5" />
|
viewDescription="查看这笔支出的详细信息"
|
||||||
{isEditing ? '编辑账单' : '账单详情'}
|
editDescription="修改这笔支出的信息"
|
||||||
</Drawer.Title>
|
onUpdate={handleRecordUpdated}
|
||||||
<Drawer.Description>
|
/>
|
||||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
|
|
||||||
</Drawer.Description>
|
|
||||||
</Drawer.Header>
|
|
||||||
|
|
||||||
{#if selectedRecord}
|
|
||||||
{#if isEditing}
|
|
||||||
<!-- 编辑表单 -->
|
|
||||||
<div class="space-y-4 py-4 px-4 md:px-0">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>金额</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
bind:value={editForm.amount}
|
|
||||||
class="pl-8"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>商家</Label>
|
|
||||||
<Input bind:value={editForm.merchant} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>分类</Label>
|
|
||||||
{#if categories.length > 0}
|
|
||||||
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
<span>{editForm.category || '选择分类'}</span>
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Portal>
|
|
||||||
<Select.Content>
|
|
||||||
{#each categories as category}
|
|
||||||
<Select.Item value={category}>{category}</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Portal>
|
|
||||||
</Select.Root>
|
|
||||||
{:else}
|
|
||||||
<Input bind:value={editForm.category} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>描述</Label>
|
|
||||||
<Input bind:value={editForm.description} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>支付方式</Label>
|
|
||||||
<Input bind:value={editForm.payment_method} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- 详情展示 -->
|
|
||||||
<div class="py-4 px-4 md:px-0">
|
|
||||||
<div class="text-center mb-6">
|
|
||||||
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">
|
|
||||||
¥{selectedRecord.amount}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-muted-foreground mt-1">
|
|
||||||
支出金额
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
|
||||||
<Store class="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="text-xs text-muted-foreground">商家</div>
|
|
||||||
<div class="font-medium truncate">{selectedRecord.merchant}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
|
||||||
<Tag class="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="text-xs text-muted-foreground">分类</div>
|
|
||||||
<div class="font-medium">{selectedRecord.category}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
|
||||||
<Calendar class="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="text-xs text-muted-foreground">时间</div>
|
|
||||||
<div class="font-medium">{selectedRecord.time}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if selectedRecord.description}
|
|
||||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
|
||||||
<FileText class="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="text-xs text-muted-foreground">描述</div>
|
|
||||||
<div class="font-medium">{selectedRecord.description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if selectedRecord.payment_method}
|
|
||||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
|
||||||
<CreditCard class="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="text-xs text-muted-foreground">支付方式</div>
|
|
||||||
<div class="font-medium">{selectedRecord.payment_method}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Drawer.Footer>
|
|
||||||
{#if isEditing}
|
|
||||||
<Button variant="outline" onclick={cancelEdit}>
|
|
||||||
<X class="h-4 w-4 mr-2" />
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onclick={saveEdit}>
|
|
||||||
<Save class="h-4 w-4 mr-2" />
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<Button variant="outline" onclick={() => detailDialogOpen = false}>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
<Button onclick={startEdit}>
|
|
||||||
<Pencil class="h-4 w-4 mr-2" />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</Drawer.Footer>
|
|
||||||
</Drawer.Content>
|
|
||||||
</Drawer.Root>
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
|
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
|
||||||
import ListIcon from '@lucide/svelte/icons/list';
|
import ListIcon from '@lucide/svelte/icons/list';
|
||||||
import type { CategoryStat, PieChartDataItem } from '$lib/types/analysis';
|
import type { CategoryStat, PieChartDataItem } from '$lib/types/analysis';
|
||||||
import type { BillRecord } from '$lib/api';
|
import type { UIBill } from '$lib/models/bill';
|
||||||
import { getPercentage } from '$lib/services/analysis';
|
import { getPercentage } from '$lib/services/analysis';
|
||||||
import { barColors } from '$lib/constants/chart';
|
import { barColors } from '$lib/constants/chart';
|
||||||
import BillRecordsTable from './BillRecordsTable.svelte';
|
import BillRecordsTable from './BillRecordsTable.svelte';
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
categoryStats: CategoryStat[];
|
categoryStats: CategoryStat[];
|
||||||
pieChartData: PieChartDataItem[];
|
pieChartData: PieChartDataItem[];
|
||||||
totalExpense: number;
|
totalExpense: number;
|
||||||
records: BillRecord[];
|
records: UIBill[];
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
// 获取选中分类的账单记录
|
// 获取选中分类的账单记录
|
||||||
let selectedRecords = $derived.by(() => {
|
let selectedRecords = $derived.by(() => {
|
||||||
if (!selectedCategory) return [];
|
if (!selectedCategory) return [];
|
||||||
return records.filter(r => r.category === selectedCategory && r.income_expense === '支出');
|
return records.filter(r => r.category === selectedCategory && r.incomeExpense === '支出');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 选中分类的统计
|
// 选中分类的统计
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
|
|
||||||
<!-- 分类详情弹窗 -->
|
<!-- 分类详情弹窗 -->
|
||||||
<Drawer.Root bind:open={dialogOpen}>
|
<Drawer.Root bind:open={dialogOpen}>
|
||||||
<Drawer.Content class="sm:max-w-4xl">
|
<Drawer.Content class="md:max-w-4xl">
|
||||||
<Drawer.Header>
|
<Drawer.Header>
|
||||||
<Drawer.Title class="flex items-center gap-2">
|
<Drawer.Title class="flex items-center gap-2">
|
||||||
<PieChartIcon class="h-5 w-5" />
|
<PieChartIcon class="h-5 w-5" />
|
||||||
|
|||||||
@@ -9,13 +9,13 @@
|
|||||||
import AreaChart from '@lucide/svelte/icons/area-chart';
|
import AreaChart from '@lucide/svelte/icons/area-chart';
|
||||||
import LineChart from '@lucide/svelte/icons/line-chart';
|
import LineChart from '@lucide/svelte/icons/line-chart';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import type { BillRecord } from '$lib/api';
|
import type { UIBill } from '$lib/models/bill';
|
||||||
import { pieColors } from '$lib/constants/chart';
|
import { pieColors } from '$lib/constants/chart';
|
||||||
import { formatLocalDate } from '$lib/utils';
|
import { formatLocalDate } from '$lib/utils';
|
||||||
import BillRecordsTable from './BillRecordsTable.svelte';
|
import BillRecordsTable from './BillRecordsTable.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
records: BillRecord[];
|
records: UIBill[];
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
// Dialog 状态
|
// Dialog 状态
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
let selectedDate = $state<Date | null>(null);
|
let selectedDate = $state<Date | null>(null);
|
||||||
let selectedDateRecords = $state<BillRecord[]>([]);
|
let selectedDateRecords = $state<UIBill[]>([]);
|
||||||
|
|
||||||
// 时间范围选项
|
// 时间范围选项
|
||||||
type TimeRange = '7d' | 'week' | '30d' | 'month' | '3m' | 'year';
|
type TimeRange = '7d' | 'week' | '30d' | 'month' | '3m' | 'year';
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
|
|
||||||
// 过滤支出记录
|
// 过滤支出记录
|
||||||
const expenseRecords = records.filter(r => {
|
const expenseRecords = records.filter(r => {
|
||||||
if (r.income_expense !== '支出') return false;
|
if (r.incomeExpense !== '支出') return false;
|
||||||
const recordDate = new Date(extractDateStr(r.time));
|
const recordDate = new Date(extractDateStr(r.time));
|
||||||
return recordDate >= cutoffDate;
|
return recordDate >= cutoffDate;
|
||||||
});
|
});
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
expenseRecords.forEach(record => {
|
expenseRecords.forEach(record => {
|
||||||
const dateStr = extractDateStr(record.time);
|
const dateStr = extractDateStr(record.time);
|
||||||
const category = record.category || '其他';
|
const category = record.category || '其他';
|
||||||
const amount = parseFloat(record.amount) || 0;
|
const amount = record.amount || 0;
|
||||||
|
|
||||||
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
|
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
|
||||||
|
|
||||||
@@ -502,6 +502,19 @@
|
|||||||
tooltipData = null;
|
tooltipData = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDateDetails(clickedDate: Date) {
|
||||||
|
const dateStr = formatLocalDate(clickedDate);
|
||||||
|
|
||||||
|
selectedDate = clickedDate;
|
||||||
|
selectedDateRecords = records.filter(r => {
|
||||||
|
if (r.incomeExpense !== '支出') return false;
|
||||||
|
const recordDateStr = extractDateStr(r.time);
|
||||||
|
return recordDateStr === dateStr;
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
// 点击打开 Dialog
|
// 点击打开 Dialog
|
||||||
function handleClick(event: MouseEvent, data: any[], maxValue: number) {
|
function handleClick(event: MouseEvent, data: any[], maxValue: number) {
|
||||||
if (data.length === 0) return;
|
if (data.length === 0) return;
|
||||||
@@ -527,17 +540,7 @@
|
|||||||
|
|
||||||
// 点击图表任意位置都触发,选择最近的日期
|
// 点击图表任意位置都触发,选择最近的日期
|
||||||
const clickedDate = data[closestIdx].date;
|
const clickedDate = data[closestIdx].date;
|
||||||
const dateStr = formatLocalDate(clickedDate);
|
openDateDetails(clickedDate);
|
||||||
|
|
||||||
// 找出当天的所有支出记录
|
|
||||||
selectedDate = clickedDate;
|
|
||||||
selectedDateRecords = records.filter(r => {
|
|
||||||
if (r.income_expense !== '支出') return false;
|
|
||||||
const recordDateStr = extractDateStr(r.time);
|
|
||||||
return recordDateStr === dateStr;
|
|
||||||
});
|
|
||||||
|
|
||||||
dialogOpen = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算选中日期的统计
|
// 计算选中日期的统计
|
||||||
@@ -549,7 +552,7 @@
|
|||||||
|
|
||||||
selectedDateRecords.forEach(r => {
|
selectedDateRecords.forEach(r => {
|
||||||
const cat = r.category || '其他';
|
const cat = r.category || '其他';
|
||||||
const amount = parseFloat(r.amount) || 0;
|
const amount = r.amount || 0;
|
||||||
total += amount;
|
total += amount;
|
||||||
|
|
||||||
if (!categoryMap.has(cat)) {
|
if (!categoryMap.has(cat)) {
|
||||||
@@ -648,16 +651,22 @@
|
|||||||
|
|
||||||
<!-- 趋势图 (自定义 SVG) -->
|
<!-- 趋势图 (自定义 SVG) -->
|
||||||
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
|
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 {chartWidth} {chartHeight}"
|
viewBox="0 0 {chartWidth} {chartHeight}"
|
||||||
class="w-full h-full cursor-pointer outline-none focus:outline-none"
|
class="w-full h-full cursor-pointer outline-none focus:outline-none"
|
||||||
role="application"
|
role="button"
|
||||||
aria-label="每日支出趋势图表,点击可查看当日详情"
|
aria-label="每日支出趋势图表,点击可查看当日详情"
|
||||||
tabindex="-1"
|
tabindex="0"
|
||||||
onmousemove={(e) => handleMouseMove(e, data, maxValue)}
|
onmousemove={(e) => handleMouseMove(e, data, maxValue)}
|
||||||
onmouseleave={handleMouseLeave}
|
onmouseleave={handleMouseLeave}
|
||||||
onclick={(e) => handleClick(e, data, maxValue)}
|
onclick={(e) => handleClick(e, data, maxValue)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
const last = data[data.length - 1];
|
||||||
|
if (last?.date) openDateDetails(last.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<!-- Y 轴 -->
|
<!-- Y 轴 -->
|
||||||
<line
|
<line
|
||||||
@@ -836,7 +845,7 @@
|
|||||||
|
|
||||||
<!-- 当日详情 Drawer -->
|
<!-- 当日详情 Drawer -->
|
||||||
<Drawer.Root bind:open={dialogOpen}>
|
<Drawer.Root bind:open={dialogOpen}>
|
||||||
<Drawer.Content class="sm:max-w-4xl">
|
<Drawer.Content class="md:max-w-4xl">
|
||||||
<Drawer.Header>
|
<Drawer.Header>
|
||||||
<Drawer.Title class="flex items-center gap-2">
|
<Drawer.Title class="flex items-center gap-2">
|
||||||
<Calendar class="h-5 w-5" />
|
<Calendar class="h-5 w-5" />
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
import Wallet from '@lucide/svelte/icons/wallet';
|
import Wallet from '@lucide/svelte/icons/wallet';
|
||||||
import type { TotalStats } from '$lib/types/analysis';
|
import type { TotalStats } from '$lib/types/analysis';
|
||||||
import { countByType } from '$lib/services/analysis';
|
import { countByType } from '$lib/services/analysis';
|
||||||
import type { BillRecord } from '$lib/api';
|
import type { UIBill } from '$lib/models/bill';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
totalStats: TotalStats;
|
totalStats: TotalStats;
|
||||||
records: BillRecord[];
|
records: UIBill[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { totalStats, records }: Props = $props();
|
let { totalStats, records }: Props = $props();
|
||||||
|
|||||||
@@ -1,97 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import * as Drawer from '$lib/components/ui/drawer';
|
|
||||||
import * as Select from '$lib/components/ui/select';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import Flame from '@lucide/svelte/icons/flame';
|
import Flame from '@lucide/svelte/icons/flame';
|
||||||
import Receipt from '@lucide/svelte/icons/receipt';
|
import { type UIBill } from '$lib/models/bill';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import BillDetailDrawer from './BillDetailDrawer.svelte';
|
||||||
import Save from '@lucide/svelte/icons/save';
|
|
||||||
import X from '@lucide/svelte/icons/x';
|
|
||||||
import Calendar from '@lucide/svelte/icons/calendar';
|
|
||||||
import Store from '@lucide/svelte/icons/store';
|
|
||||||
import Tag from '@lucide/svelte/icons/tag';
|
|
||||||
import FileText from '@lucide/svelte/icons/file-text';
|
|
||||||
import CreditCard from '@lucide/svelte/icons/credit-card';
|
|
||||||
import type { BillRecord } from '$lib/api';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
records: BillRecord[];
|
records: UIBill[];
|
||||||
categories: string[]; // 可用的分类列表
|
categories: string[]; // 可用的分类列表
|
||||||
onUpdate?: (record: BillRecord) => void;
|
onUpdate?: (record: UIBill) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { records, categories, onUpdate }: Props = $props();
|
let { records, categories, onUpdate }: Props = $props();
|
||||||
|
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
let selectedRecord = $state<BillRecord | null>(null);
|
let selectedRecord = $state<UIBill | null>(null);
|
||||||
let selectedRank = $state(0);
|
let selectedRank = $state(0);
|
||||||
let isEditing = $state(false);
|
|
||||||
|
|
||||||
// 编辑表单数据
|
|
||||||
let editForm = $state({
|
|
||||||
merchant: '',
|
|
||||||
category: '',
|
|
||||||
amount: '',
|
|
||||||
description: '',
|
|
||||||
payment_method: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
function openDetail(record: BillRecord, rank: number) {
|
function openDetail(record: UIBill, rank: number) {
|
||||||
selectedRecord = record;
|
selectedRecord = record;
|
||||||
selectedRank = rank;
|
selectedRank = rank;
|
||||||
isEditing = false;
|
|
||||||
dialogOpen = true;
|
dialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit() {
|
function handleRecordUpdated(updated: UIBill, original: UIBill) {
|
||||||
if (!selectedRecord) return;
|
const idx = records.findIndex(r => r === original);
|
||||||
editForm = {
|
if (idx !== -1) records[idx] = updated;
|
||||||
merchant: selectedRecord.merchant,
|
selectedRecord = updated;
|
||||||
category: selectedRecord.category,
|
onUpdate?.(updated);
|
||||||
amount: selectedRecord.amount,
|
|
||||||
description: selectedRecord.description || '',
|
|
||||||
payment_method: selectedRecord.payment_method || ''
|
|
||||||
};
|
|
||||||
isEditing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit() {
|
|
||||||
isEditing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveEdit() {
|
|
||||||
if (!selectedRecord) return;
|
|
||||||
|
|
||||||
// 更新记录
|
|
||||||
const updatedRecord: BillRecord = {
|
|
||||||
...selectedRecord,
|
|
||||||
merchant: editForm.merchant,
|
|
||||||
category: editForm.category,
|
|
||||||
amount: editForm.amount,
|
|
||||||
description: editForm.description,
|
|
||||||
payment_method: editForm.payment_method
|
|
||||||
};
|
|
||||||
|
|
||||||
// 更新本地数据
|
|
||||||
const index = records.findIndex(r => r === selectedRecord);
|
|
||||||
if (index !== -1) {
|
|
||||||
records[index] = updatedRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedRecord = updatedRecord;
|
|
||||||
isEditing = false;
|
|
||||||
|
|
||||||
// 通知父组件
|
|
||||||
onUpdate?.(updatedRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCategoryChange(value: string | undefined) {
|
|
||||||
if (value) {
|
|
||||||
editForm.category = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -125,7 +60,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="font-mono font-bold text-red-600 dark:text-red-400">
|
<div class="font-mono font-bold text-red-600 dark:text-red-400">
|
||||||
¥{record.amount}
|
¥{record.amount.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -133,157 +68,24 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<!-- 账单详情弹窗 -->
|
<BillDetailDrawer
|
||||||
<Drawer.Root bind:open={dialogOpen}>
|
bind:open={dialogOpen}
|
||||||
<Drawer.Content class="sm:max-w-[450px]">
|
bind:record={selectedRecord}
|
||||||
<Drawer.Header>
|
{categories}
|
||||||
<Drawer.Title class="flex items-center gap-2">
|
title="账单详情"
|
||||||
<Receipt class="h-5 w-5" />
|
viewDescription="查看这笔支出的完整信息"
|
||||||
{isEditing ? '编辑账单' : '账单详情'}
|
editDescription="修改这笔支出的信息"
|
||||||
{#if selectedRank <= 3 && !isEditing}
|
onUpdate={handleRecordUpdated}
|
||||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {
|
>
|
||||||
selectedRank === 1 ? 'bg-gradient-to-r from-yellow-400 to-amber-500 text-white' :
|
{#snippet titleExtra({ isEditing })}
|
||||||
selectedRank === 2 ? 'bg-gradient-to-r from-slate-300 to-slate-400 text-white' :
|
{#if selectedRank <= 3 && !isEditing}
|
||||||
'bg-gradient-to-r from-orange-400 to-amber-600 text-white'
|
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {
|
||||||
}">
|
selectedRank === 1 ? 'bg-gradient-to-r from-yellow-400 to-amber-500 text-white' :
|
||||||
Top {selectedRank}
|
selectedRank === 2 ? 'bg-gradient-to-r from-slate-300 to-slate-400 text-white' :
|
||||||
</span>
|
'bg-gradient-to-r from-orange-400 to-amber-600 text-white'
|
||||||
{/if}
|
}">
|
||||||
</Drawer.Title>
|
Top {selectedRank}
|
||||||
<Drawer.Description>
|
</span>
|
||||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
|
|
||||||
</Drawer.Description>
|
|
||||||
</Drawer.Header>
|
|
||||||
|
|
||||||
{#if selectedRecord}
|
|
||||||
{#if isEditing}
|
|
||||||
<!-- 编辑模式 -->
|
|
||||||
<div class="py-4 space-y-4 px-4 md:px-0">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="amount">金额</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
|
|
||||||
<Input
|
|
||||||
id="amount"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
bind:value={editForm.amount}
|
|
||||||
class="pl-7 font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="merchant">商家</Label>
|
|
||||||
<Input id="merchant" bind:value={editForm.merchant} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>分类</Label>
|
|
||||||
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
<span>{editForm.category || '选择分类'}</span>
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Portal>
|
|
||||||
<Select.Content>
|
|
||||||
{#each categories as category}
|
|
||||||
<Select.Item value={category}>{category}</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Portal>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="description">描述</Label>
|
|
||||||
<Input id="description" bind:value={editForm.description} placeholder="可选" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="payment_method">支付方式</Label>
|
|
||||||
<Input id="payment_method" bind:value={editForm.payment_method} placeholder="可选" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- 查看模式 -->
|
|
||||||
<div class="py-4 space-y-4 px-4 md:px-0">
|
|
||||||
<!-- 金额 -->
|
|
||||||
<div class="text-center py-4 bg-red-50 dark:bg-red-950/30 rounded-lg">
|
|
||||||
<p class="text-sm text-muted-foreground mb-1">支出金额</p>
|
|
||||||
<p class="text-3xl font-bold font-mono text-red-600 dark:text-red-400">
|
|
||||||
¥{selectedRecord.amount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 详情列表 -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
|
||||||
<Store class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-xs text-muted-foreground">商家</p>
|
|
||||||
<p class="font-medium">{selectedRecord.merchant}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
|
||||||
<Tag class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-xs text-muted-foreground">分类</p>
|
|
||||||
<p class="font-medium">{selectedRecord.category}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
|
||||||
<Calendar class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-xs text-muted-foreground">时间</p>
|
|
||||||
<p class="font-medium">{selectedRecord.time}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if selectedRecord.description}
|
|
||||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
|
||||||
<FileText class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-xs text-muted-foreground">描述</p>
|
|
||||||
<p class="font-medium">{selectedRecord.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if selectedRecord.payment_method}
|
|
||||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
|
||||||
<CreditCard class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-xs text-muted-foreground">支付方式</p>
|
|
||||||
<p class="font-medium">{selectedRecord.payment_method}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
<Drawer.Footer class="flex gap-2">
|
</BillDetailDrawer>
|
||||||
{#if isEditing}
|
|
||||||
<Button variant="outline" onclick={cancelEdit}>
|
|
||||||
<X class="h-4 w-4 mr-2" />
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onclick={saveEdit}>
|
|
||||||
<Save class="h-4 w-4 mr-2" />
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<Button variant="outline" onclick={() => dialogOpen = false}>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
<Button onclick={startEdit}>
|
|
||||||
<Pencil class="h-4 w-4 mr-2" />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</Drawer.Footer>
|
|
||||||
</Drawer.Content>
|
|
||||||
</Drawer.Root>
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
config: ChartConfig;
|
config: ChartConfig;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
|
let chartId = $derived.by(() => `chart-${id || uid.replace(/:/g, "")}`);
|
||||||
|
|
||||||
setChartContext({
|
setChartContext({
|
||||||
get config() {
|
get config() {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
let { startDate = $bindable(), endDate = $bindable(), onchange, class: className }: Props = $props();
|
let { startDate = $bindable(), endDate = $bindable(), onchange, class: className }: Props = $props();
|
||||||
|
|
||||||
// 将 YYYY-MM-DD 字符串转换为 CalendarDate
|
// 将 YYYY-MM-DD 字符串转换为 CalendarDate
|
||||||
function parseDate(dateStr: string): DateValue | undefined {
|
function parseDate(dateStr?: string): DateValue | undefined {
|
||||||
if (!dateStr) return undefined;
|
if (!dateStr) return undefined;
|
||||||
const [year, month, day] = dateStr.split('-').map(Number);
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
return new CalendarDate(year, month, day);
|
return new CalendarDate(year, month, day);
|
||||||
@@ -101,7 +101,11 @@
|
|||||||
<Popover.Content class="w-auto p-0" align="start">
|
<Popover.Content class="w-auto p-0" align="start">
|
||||||
<RangeCalendar
|
<RangeCalendar
|
||||||
bind:value
|
bind:value
|
||||||
|
class="rounded-md border"
|
||||||
numberOfMonths={2}
|
numberOfMonths={2}
|
||||||
|
pagedNavigation={true}
|
||||||
|
fixedWeeks={true}
|
||||||
|
weekdayFormat="short"
|
||||||
locale="zh-CN"
|
locale="zh-CN"
|
||||||
weekStartsOn={1}
|
weekStartsOn={1}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-4 left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-0 gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg max-h-[calc(100dvh-2rem)] overflow-y-auto md:top-[50%] md:translate-y-[-50%] md:max-h-[85vh]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -20,14 +20,14 @@
|
|||||||
{#if isMobile.current}
|
{#if isMobile.current}
|
||||||
<Sheet.Content
|
<Sheet.Content
|
||||||
{side}
|
{side}
|
||||||
class={cn('max-h-[90vh] overflow-hidden flex flex-col', className)}
|
class={cn('max-h-[90vh] overflow-y-auto flex flex-col', className)}
|
||||||
>
|
>
|
||||||
<!-- 拖拽指示器 (移动端抽屉常见设计) -->
|
<!-- 拖拽指示器 (移动端抽屉常见设计) -->
|
||||||
<div class="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted"></div>
|
<div class="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted"></div>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</Sheet.Content>
|
</Sheet.Content>
|
||||||
{:else}
|
{:else}
|
||||||
<Dialog.Content class={cn('max-h-[85vh] overflow-hidden flex flex-col', className)}>
|
<Dialog.Content class={cn('max-h-[85vh] overflow-y-auto flex flex-col', className)}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import Monitor from '@lucide/svelte/icons/monitor';
|
import Monitor from '@lucide/svelte/icons/monitor';
|
||||||
import Sun from '@lucide/svelte/icons/sun';
|
import Sun from '@lucide/svelte/icons/sun';
|
||||||
import Moon from '@lucide/svelte/icons/moon';
|
import Moon from '@lucide/svelte/icons/moon';
|
||||||
import type { ComponentType } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
export type ThemeMode = 'system' | 'light' | 'dark';
|
export type ThemeMode = 'system' | 'light' | 'dark';
|
||||||
|
|
||||||
export interface ThemeOption {
|
export interface ThemeOption {
|
||||||
label: string;
|
label: string;
|
||||||
icon: ComponentType;
|
icon: Component;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const themeConfig: Record<ThemeMode, ThemeOption> = {
|
export const themeConfig: Record<ThemeMode, ThemeOption> = {
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import type { BillRecord } from '$lib/api';
|
import type { UIBill } from '$lib/models/bill';
|
||||||
|
|
||||||
|
type DemoBillRow = {
|
||||||
|
time: string;
|
||||||
|
category: string;
|
||||||
|
merchant: string;
|
||||||
|
description: string;
|
||||||
|
income_expense: string;
|
||||||
|
amount: string;
|
||||||
|
payment_method: string;
|
||||||
|
status: string;
|
||||||
|
remark: string;
|
||||||
|
needs_review: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 真实账单数据(来自支付宝和微信导出)
|
* 真实账单数据(来自支付宝和微信导出)
|
||||||
* 数据已脱敏处理
|
* 数据已脱敏处理
|
||||||
*/
|
*/
|
||||||
export const demoRecords: BillRecord[] = [
|
const demoRows: DemoBillRow[] = [
|
||||||
// ========== 支付宝数据 ==========
|
// ========== 支付宝数据 ==========
|
||||||
{ time: "2026-01-07 12:01:02", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
{ time: "2026-01-07 12:01:02", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
{ time: "2026-01-06 15:54:53", category: "餐饮美食", merchant: "友宝", description: "智能货柜消费", income_expense: "支出", amount: "7.19", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
{ time: "2026-01-06 15:54:53", category: "餐饮美食", merchant: "友宝", description: "智能货柜消费", income_expense: "支出", amount: "7.19", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
@@ -167,3 +180,16 @@ export const demoRecords: BillRecord[] = [
|
|||||||
{ time: "2025-12-08 19:15:45", category: "餐饮美食", merchant: "瑞幸咖啡", description: "咖啡", income_expense: "支出", amount: "12.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
{ time: "2025-12-08 19:15:45", category: "餐饮美食", merchant: "瑞幸咖啡", description: "咖啡", income_expense: "支出", amount: "12.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ time: "2025-12-07 18:42:19", category: "餐饮美食", merchant: "奶茶店", description: "饮品", income_expense: "支出", amount: "15.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
{ time: "2025-12-07 18:42:19", category: "餐饮美食", merchant: "奶茶店", description: "饮品", income_expense: "支出", amount: "15.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
].sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
].sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
||||||
|
|
||||||
|
export const demoRecords: UIBill[] = demoRows.map((r) => ({
|
||||||
|
time: r.time,
|
||||||
|
category: r.category,
|
||||||
|
merchant: r.merchant,
|
||||||
|
description: r.description || '',
|
||||||
|
incomeExpense: r.income_expense,
|
||||||
|
amount: Number(r.amount || 0),
|
||||||
|
paymentMethod: r.payment_method || '',
|
||||||
|
status: r.status || '',
|
||||||
|
remark: r.remark || '',
|
||||||
|
reviewLevel: r.needs_review || '',
|
||||||
|
}));
|
||||||
|
|||||||
45
web/src/lib/models/bill.ts
Normal file
45
web/src/lib/models/bill.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { CleanedBill, UpdateBillRequest } from '$lib/api';
|
||||||
|
|
||||||
|
export interface UIBill {
|
||||||
|
id?: string;
|
||||||
|
time: string;
|
||||||
|
category: string;
|
||||||
|
merchant: string;
|
||||||
|
description?: string;
|
||||||
|
incomeExpense: string;
|
||||||
|
amount: number;
|
||||||
|
paymentMethod?: string;
|
||||||
|
status?: string;
|
||||||
|
remark?: string;
|
||||||
|
reviewLevel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanedBillToUIBill(bill: CleanedBill): UIBill {
|
||||||
|
return {
|
||||||
|
id: bill.id,
|
||||||
|
time: bill.time,
|
||||||
|
category: bill.category,
|
||||||
|
merchant: bill.merchant,
|
||||||
|
description: bill.description || '',
|
||||||
|
incomeExpense: bill.income_expense,
|
||||||
|
amount: bill.amount,
|
||||||
|
paymentMethod: bill.pay_method || '',
|
||||||
|
status: bill.status || '',
|
||||||
|
remark: bill.remark || '',
|
||||||
|
reviewLevel: bill.review_level || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uiBillToUpdateBillRequest(bill: UIBill): UpdateBillRequest {
|
||||||
|
return {
|
||||||
|
time: bill.time,
|
||||||
|
category: bill.category,
|
||||||
|
merchant: bill.merchant,
|
||||||
|
description: bill.description,
|
||||||
|
income_expense: bill.incomeExpense,
|
||||||
|
amount: bill.amount,
|
||||||
|
pay_method: bill.paymentMethod,
|
||||||
|
status: bill.status,
|
||||||
|
remark: bill.remark,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { BillRecord } from '$lib/api';
|
import type { UIBill } from '$lib/models/bill';
|
||||||
import type { CategoryStat, MonthlyStat, DailyExpenseData, TotalStats, PieChartDataItem } from '$lib/types/analysis';
|
import type { CategoryStat, MonthlyStat, DailyExpenseData, TotalStats, PieChartDataItem } from '$lib/types/analysis';
|
||||||
import { pieColors } from '$lib/constants/chart';
|
import { pieColors } from '$lib/constants/chart';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算分类统计
|
* 计算分类统计
|
||||||
*/
|
*/
|
||||||
export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] {
|
export function calculateCategoryStats(records: UIBill[]): CategoryStat[] {
|
||||||
const stats = new Map<string, { expense: number; income: number; count: number }>();
|
const stats = new Map<string, { expense: number; income: number; count: number }>();
|
||||||
|
|
||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
@@ -14,8 +14,8 @@ export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] {
|
|||||||
}
|
}
|
||||||
const s = stats.get(r.category)!;
|
const s = stats.get(r.category)!;
|
||||||
s.count++;
|
s.count++;
|
||||||
const amount = parseFloat(r.amount || '0');
|
const amount = r.amount || 0;
|
||||||
if (r.income_expense === '支出') {
|
if (r.incomeExpense === '支出') {
|
||||||
s.expense += amount;
|
s.expense += amount;
|
||||||
} else {
|
} else {
|
||||||
s.income += amount;
|
s.income += amount;
|
||||||
@@ -30,7 +30,7 @@ export function calculateCategoryStats(records: BillRecord[]): CategoryStat[] {
|
|||||||
/**
|
/**
|
||||||
* 计算月度统计
|
* 计算月度统计
|
||||||
*/
|
*/
|
||||||
export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] {
|
export function calculateMonthlyStats(records: UIBill[]): MonthlyStat[] {
|
||||||
const stats = new Map<string, { expense: number; income: number }>();
|
const stats = new Map<string, { expense: number; income: number }>();
|
||||||
|
|
||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
@@ -39,8 +39,8 @@ export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] {
|
|||||||
stats.set(month, { expense: 0, income: 0 });
|
stats.set(month, { expense: 0, income: 0 });
|
||||||
}
|
}
|
||||||
const s = stats.get(month)!;
|
const s = stats.get(month)!;
|
||||||
const amount = parseFloat(r.amount || '0');
|
const amount = r.amount || 0;
|
||||||
if (r.income_expense === '支出') {
|
if (r.incomeExpense === '支出') {
|
||||||
s.expense += amount;
|
s.expense += amount;
|
||||||
} else {
|
} else {
|
||||||
s.income += amount;
|
s.income += amount;
|
||||||
@@ -55,13 +55,13 @@ export function calculateMonthlyStats(records: BillRecord[]): MonthlyStat[] {
|
|||||||
/**
|
/**
|
||||||
* 计算每日支出数据(用于面积图)
|
* 计算每日支出数据(用于面积图)
|
||||||
*/
|
*/
|
||||||
export function calculateDailyExpenseData(records: BillRecord[]): DailyExpenseData[] {
|
export function calculateDailyExpenseData(records: UIBill[]): DailyExpenseData[] {
|
||||||
const stats = new Map<string, number>();
|
const stats = new Map<string, number>();
|
||||||
|
|
||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
if (r.income_expense !== '支出') continue;
|
if (r.incomeExpense !== '支出') continue;
|
||||||
const date = r.time.substring(0, 10); // YYYY-MM-DD
|
const date = r.time.substring(0, 10); // YYYY-MM-DD
|
||||||
const amount = parseFloat(r.amount || '0');
|
const amount = r.amount || 0;
|
||||||
stats.set(date, (stats.get(date) || 0) + amount);
|
stats.set(date, (stats.get(date) || 0) + amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,14 +73,14 @@ export function calculateDailyExpenseData(records: BillRecord[]): DailyExpenseDa
|
|||||||
/**
|
/**
|
||||||
* 计算总计统计
|
* 计算总计统计
|
||||||
*/
|
*/
|
||||||
export function calculateTotalStats(records: BillRecord[]): TotalStats {
|
export function calculateTotalStats(records: UIBill[]): TotalStats {
|
||||||
return {
|
return {
|
||||||
expense: records
|
expense: records
|
||||||
.filter(r => r.income_expense === '支出')
|
.filter(r => r.incomeExpense === '支出')
|
||||||
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
|
.reduce((sum, r) => sum + (r.amount || 0), 0),
|
||||||
income: records
|
income: records
|
||||||
.filter(r => r.income_expense === '收入')
|
.filter(r => r.incomeExpense === '收入')
|
||||||
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
|
.reduce((sum, r) => sum + (r.amount || 0), 0),
|
||||||
count: records.length,
|
count: records.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -112,18 +112,18 @@ export function calculatePieChartData(
|
|||||||
/**
|
/**
|
||||||
* 获取 Top N 支出记录
|
* 获取 Top N 支出记录
|
||||||
*/
|
*/
|
||||||
export function getTopExpenses(records: BillRecord[], n: number = 10): BillRecord[] {
|
export function getTopExpenses(records: UIBill[], n: number = 10): UIBill[] {
|
||||||
return records
|
return records
|
||||||
.filter(r => r.income_expense === '支出')
|
.filter(r => r.incomeExpense === '支出')
|
||||||
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount))
|
.sort((a, b) => (b.amount || 0) - (a.amount || 0))
|
||||||
.slice(0, n);
|
.slice(0, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计支出/收入笔数
|
* 统计支出/收入笔数
|
||||||
*/
|
*/
|
||||||
export function countByType(records: BillRecord[], type: '支出' | '收入'): number {
|
export function countByType(records: UIBill[], type: '支出' | '收入'): number {
|
||||||
return records.filter(r => r.income_expense === type).length;
|
return records.filter(r => r.incomeExpense === type).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BillRecord } from '$lib/api';
|
import type { UIBill } from '$lib/models/bill';
|
||||||
|
|
||||||
/** 分类统计数据 */
|
/** 分类统计数据 */
|
||||||
export interface CategoryStat {
|
export interface CategoryStat {
|
||||||
@@ -47,7 +47,7 @@ export interface AnalysisState {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
records: BillRecord[];
|
records: UIBill[];
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
categoryChartMode: 'bar' | 'pie';
|
categoryChartMode: 'bar' | 'pie';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,35 +23,44 @@
|
|||||||
let isUploading = $state(false);
|
let isUploading = $state(false);
|
||||||
let uploadResult: UploadResponse | null = $state(null);
|
let uploadResult: UploadResponse | null = $state(null);
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
|
|
||||||
|
type StatTrend = 'up' | 'down';
|
||||||
|
interface StatCard {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
change: string;
|
||||||
|
trend: StatTrend;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 实时统计数据
|
// 实时统计数据
|
||||||
let stats = $state([
|
let stats = $state<StatCard[]>([
|
||||||
{
|
{
|
||||||
title: '本月支出',
|
title: '本月支出',
|
||||||
value: '¥0.00',
|
value: '¥0.00',
|
||||||
change: '+0%',
|
change: '+0%',
|
||||||
trend: 'up' as const,
|
trend: 'up',
|
||||||
description: '加载中...'
|
description: '加载中...'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '本月收入',
|
title: '本月收入',
|
||||||
value: '¥0.00',
|
value: '¥0.00',
|
||||||
change: '+0%',
|
change: '+0%',
|
||||||
trend: 'up' as const,
|
trend: 'up',
|
||||||
description: '加载中...'
|
description: '加载中...'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '待复核',
|
title: '待复核',
|
||||||
value: '0',
|
value: '0',
|
||||||
change: '+0%',
|
change: '+0%',
|
||||||
trend: 'up' as const,
|
trend: 'up',
|
||||||
description: '需要人工确认'
|
description: '需要人工确认'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '已处理账单',
|
title: '已处理账单',
|
||||||
value: '0',
|
value: '0',
|
||||||
change: '+0%',
|
change: '+0%',
|
||||||
trend: 'up' as const,
|
trend: 'up',
|
||||||
description: '累计处理记录'
|
description: '累计处理记录'
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -66,62 +75,49 @@
|
|||||||
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
const previousMonth = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, '0')}`;
|
const previousMonth = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
console.log('Current month:', currentMonth);
|
|
||||||
console.log('Previous month:', previousMonth);
|
|
||||||
|
|
||||||
// 获取月度统计数据
|
// 获取月度统计数据
|
||||||
const monthlyResponse = await fetchMonthlyStats();
|
const monthlyResponse = await fetchMonthlyStats();
|
||||||
console.log('Monthly response:', monthlyResponse);
|
|
||||||
const monthlyStats = monthlyResponse.data || [];
|
const monthlyStats = monthlyResponse.data || [];
|
||||||
console.log('Monthly stats:', monthlyStats);
|
|
||||||
|
|
||||||
// 获取待复核统计
|
// 获取待复核统计
|
||||||
const reviewResponse = await fetchReviewStats();
|
const reviewResponse = await fetchReviewStats();
|
||||||
console.log('Review response:', reviewResponse);
|
|
||||||
const reviewTotal = reviewResponse.data?.total || 0;
|
const reviewTotal = reviewResponse.data?.total || 0;
|
||||||
console.log('Review total:', reviewTotal);
|
|
||||||
|
|
||||||
// 获取已处理账单数量
|
// 获取已处理账单数量
|
||||||
const billsResponse = await fetchBills({ page_size: 1 });
|
const billsResponse = await fetchBills({ page_size: 1 });
|
||||||
console.log('Bills response:', billsResponse);
|
|
||||||
const billTotal = billsResponse.data?.total || 0;
|
const billTotal = billsResponse.data?.total || 0;
|
||||||
console.log('Bill total:', billTotal);
|
|
||||||
|
|
||||||
// 提取当月和上月的数据
|
// 提取当月和上月的数据
|
||||||
const currentData = monthlyStats.find(m => m.month === currentMonth);
|
const currentData = monthlyStats.find(m => m.month === currentMonth);
|
||||||
const previousData = monthlyStats.find(m => m.month === previousMonth);
|
const previousData = monthlyStats.find(m => m.month === previousMonth);
|
||||||
|
|
||||||
console.log('Current data:', currentData);
|
|
||||||
console.log('Previous data:', previousData);
|
|
||||||
|
|
||||||
// 计算支出变化百分比
|
// 计算支出变化百分比
|
||||||
const currentExpense = currentData?.expense || 0;
|
const currentExpense = currentData?.expense || 0;
|
||||||
const previousExpense = previousData?.expense || 0;
|
const previousExpense = previousData?.expense || 0;
|
||||||
const expenseChange = previousExpense > 0
|
const expenseChange = previousExpense > 0
|
||||||
? ((currentExpense - previousExpense) / previousExpense * 100).toFixed(1)
|
? (currentExpense - previousExpense) / previousExpense * 100
|
||||||
: 0;
|
: 0;
|
||||||
const expenseTrend = parseFloat(expenseChange.toString()) >= 0 ? 'up' : 'down';
|
const expenseTrend: StatTrend = expenseChange >= 0 ? 'up' : 'down';
|
||||||
|
|
||||||
// 计算收入变化百分比
|
// 计算收入变化百分比
|
||||||
const currentIncome = currentData?.income || 0;
|
const currentIncome = currentData?.income || 0;
|
||||||
const previousIncome = previousData?.income || 0;
|
const previousIncome = previousData?.income || 0;
|
||||||
const incomeChange = previousIncome > 0
|
const incomeChange = previousIncome > 0
|
||||||
? ((currentIncome - previousIncome) / previousIncome * 100).toFixed(1)
|
? (currentIncome - previousIncome) / previousIncome * 100
|
||||||
: 0;
|
: 0;
|
||||||
const incomeTrend = parseFloat(incomeChange.toString()) >= 0 ? 'up' : 'down';
|
const incomeTrend: StatTrend = incomeChange >= 0 ? 'up' : 'down';
|
||||||
|
|
||||||
// 格式化金额
|
// 格式化金额
|
||||||
const formatAmount = (amount: number) => {
|
const formatAmount = (amount: number) => {
|
||||||
return `¥${amount.toFixed(2)}`;
|
return `¥${amount.toFixed(2)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatChange = (change: number | string) => {
|
const formatChange = (change: number) => {
|
||||||
const changeNum = typeof change === 'string' ? parseFloat(change) : change;
|
const sign = change >= 0 ? '+' : '';
|
||||||
const sign = changeNum >= 0 ? '+' : '';
|
return `${sign}${change.toFixed(1)}%`;
|
||||||
return `${sign}${changeNum.toFixed(1)}%`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const newStats = [
|
const newStats: StatCard[] = [
|
||||||
{
|
{
|
||||||
title: '本月支出',
|
title: '本月支出',
|
||||||
value: formatAmount(currentExpense),
|
value: formatAmount(currentExpense),
|
||||||
@@ -140,19 +136,18 @@
|
|||||||
title: '待复核',
|
title: '待复核',
|
||||||
value: reviewTotal.toString(),
|
value: reviewTotal.toString(),
|
||||||
change: '+0%',
|
change: '+0%',
|
||||||
trend: 'up' as const,
|
trend: 'up',
|
||||||
description: '需要人工确认'
|
description: '需要人工确认'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '已处理账单',
|
title: '已处理账单',
|
||||||
value: billTotal.toString(),
|
value: billTotal.toString(),
|
||||||
change: '+0%',
|
change: '+0%',
|
||||||
trend: 'up' as const,
|
trend: 'up',
|
||||||
description: '累计处理记录'
|
description: '累计处理记录'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
console.log('New stats:', newStats);
|
|
||||||
stats = newStats;
|
stats = newStats;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load stats:', err);
|
console.error('Failed to load stats:', err);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fetchBills, fetchMonthlyStats, checkHealth, type CleanedBill, type MonthlyStat } from '$lib/api';
|
import { fetchBills, fetchMonthlyStats, checkHealth, type CleanedBill, type MonthlyStat } from '$lib/api';
|
||||||
|
import { cleanedBillToUIBill, type UIBill } from '$lib/models/bill';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
@@ -74,25 +75,9 @@
|
|||||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 CleanedBill 转换为分析服务需要的格式
|
// 派生分析数据(统一成 UIBill)
|
||||||
function toAnalysisRecords(bills: CleanedBill[]) {
|
let analysisRecords: UIBill[] = $derived.by(() => (isDemo ? demoRecords : records.map(cleanedBillToUIBill)));
|
||||||
return bills.map(bill => ({
|
let allAnalysisRecords: UIBill[] = $derived.by(() => (isDemo ? demoRecords : allRecords.map(cleanedBillToUIBill)));
|
||||||
time: bill.time,
|
|
||||||
category: bill.category,
|
|
||||||
merchant: bill.merchant,
|
|
||||||
description: bill.description,
|
|
||||||
income_expense: bill.income_expense,
|
|
||||||
amount: String(bill.amount),
|
|
||||||
payment_method: bill.pay_method,
|
|
||||||
status: bill.status,
|
|
||||||
remark: bill.remark,
|
|
||||||
needs_review: bill.review_level,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 派生分析数据
|
|
||||||
let analysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(records));
|
|
||||||
let allAnalysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(allRecords)); // 全部数据用于每日趋势图
|
|
||||||
let categoryStats = $derived(calculateCategoryStats(analysisRecords));
|
let categoryStats = $derived(calculateCategoryStats(analysisRecords));
|
||||||
let dailyExpenseData = $derived(calculateDailyExpenseData(analysisRecords));
|
let dailyExpenseData = $derived(calculateDailyExpenseData(analysisRecords));
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
let isLoading = $state(true);
|
let isLoading = $state(true);
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
let reviewStats: ReviewData | null = $state(null);
|
let reviewStats = $state<ReviewData | null>(null);
|
||||||
let allBills: CleanedBill[] = $state([]);
|
let allBills = $state<CleanedBill[]>([]);
|
||||||
let filterLevel = $state<'all' | 'HIGH' | 'LOW'>('all');
|
let filterLevel = $state<'all' | 'HIGH' | 'LOW'>('all');
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user