feat: server connect mongo
This commit is contained in:
@@ -10,8 +10,15 @@
|
|||||||
python clean_bill.py 账单.csv --start 2026-01-01 --end 2026-01-15
|
python clean_bill.py 账单.csv --start 2026-01-01 --end 2026-01-15
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 解决 Windows 控制台编码问题
|
||||||
|
if sys.stdout.encoding != 'utf-8':
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||||
|
if sys.stderr.encoding != 'utf-8':
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||||
|
|
||||||
from cleaners.base import create_arg_parser, compute_date_range
|
from cleaners.base import create_arg_parser, compute_date_range
|
||||||
from cleaners import AlipayCleaner, WechatCleaner
|
from cleaners import AlipayCleaner, WechatCleaner
|
||||||
|
|
||||||
|
|||||||
43
docker-compose.yaml
Normal file
43
docker-compose.yaml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
services:
|
||||||
|
mongodb:
|
||||||
|
image: mongo:8.0
|
||||||
|
container_name: billai-mongodb
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_DATABASE: billai
|
||||||
|
# 如需认证,取消下面两行注释
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: admin
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: password
|
||||||
|
volumes:
|
||||||
|
- ./mongo/data/db:/data/db
|
||||||
|
- ./mongo/data/configdb:/data/configdb
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases:
|
||||||
|
- mongo
|
||||||
|
|
||||||
|
# 可选:MongoDB 可视化管理工具
|
||||||
|
mongo-express:
|
||||||
|
image: mongo-express:latest
|
||||||
|
container_name: billai-mongo-express
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8083:8081"
|
||||||
|
environment:
|
||||||
|
ME_CONFIG_MONGODB_SERVER: mongodb
|
||||||
|
ME_CONFIG_MONGODB_PORT: 27017
|
||||||
|
ME_CONFIG_BASICAUTH: "false"
|
||||||
|
# 如启用 MongoDB 认证,取消下面两行注释
|
||||||
|
ME_CONFIG_MONGODB_ADMINUSERNAME: admin
|
||||||
|
ME_CONFIG_MONGODB_ADMINPASSWORD: password
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
BIN
server/billai-server.exe
Normal file
BIN
server/billai-server.exe
Normal file
Binary file not shown.
@@ -7,7 +7,7 @@ server:
|
|||||||
# Python 配置
|
# Python 配置
|
||||||
python:
|
python:
|
||||||
# Python 解释器路径(相对于项目根目录或绝对路径)
|
# Python 解释器路径(相对于项目根目录或绝对路径)
|
||||||
path: analyzer/venv/bin/python
|
path: analyzer/venv/Scripts/python.exe
|
||||||
# 分析脚本路径(相对于项目根目录)
|
# 分析脚本路径(相对于项目根目录)
|
||||||
script: analyzer/clean_bill.py
|
script: analyzer/clean_bill.py
|
||||||
|
|
||||||
@@ -16,3 +16,16 @@ directories:
|
|||||||
upload: server/uploads
|
upload: server/uploads
|
||||||
output: server/outputs
|
output: server/outputs
|
||||||
|
|
||||||
|
# MongoDB 配置
|
||||||
|
mongodb:
|
||||||
|
# MongoDB 连接 URI(带认证)
|
||||||
|
uri: mongodb://admin:password@localhost:27017
|
||||||
|
# 数据库名称
|
||||||
|
database: billai
|
||||||
|
# 集合名称
|
||||||
|
collections:
|
||||||
|
# 原始数据集合
|
||||||
|
raw: bills_raw
|
||||||
|
# 清洗后数据集合
|
||||||
|
cleaned: bills_cleaned
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ type Config struct {
|
|||||||
CleanScript string // 清理脚本路径
|
CleanScript string // 清理脚本路径
|
||||||
UploadDir string // 上传文件目录
|
UploadDir string // 上传文件目录
|
||||||
OutputDir string // 输出文件目录
|
OutputDir string // 输出文件目录
|
||||||
|
|
||||||
|
// MongoDB 配置
|
||||||
|
MongoURI string // MongoDB 连接 URI
|
||||||
|
MongoDatabase string // 数据库名称
|
||||||
|
MongoRawCollection string // 原始数据集合名称
|
||||||
|
MongoCleanedCollection string // 清洗后数据集合名称
|
||||||
}
|
}
|
||||||
|
|
||||||
// configFile YAML 配置文件结构
|
// configFile YAML 配置文件结构
|
||||||
@@ -32,6 +38,14 @@ type configFile struct {
|
|||||||
Upload string `yaml:"upload"`
|
Upload string `yaml:"upload"`
|
||||||
Output string `yaml:"output"`
|
Output string `yaml:"output"`
|
||||||
} `yaml:"directories"`
|
} `yaml:"directories"`
|
||||||
|
MongoDB struct {
|
||||||
|
URI string `yaml:"uri"`
|
||||||
|
Database string `yaml:"database"`
|
||||||
|
Collections struct {
|
||||||
|
Raw string `yaml:"raw"`
|
||||||
|
Cleaned string `yaml:"cleaned"`
|
||||||
|
} `yaml:"collections"`
|
||||||
|
} `yaml:"mongodb"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global 全局配置实例
|
// Global 全局配置实例
|
||||||
@@ -102,6 +116,12 @@ func Load() {
|
|||||||
Global.UploadDir = "server/uploads"
|
Global.UploadDir = "server/uploads"
|
||||||
Global.OutputDir = "server/outputs"
|
Global.OutputDir = "server/outputs"
|
||||||
|
|
||||||
|
// MongoDB 默认值
|
||||||
|
Global.MongoURI = getEnvOrDefault("MONGO_URI", "mongodb://localhost:27017")
|
||||||
|
Global.MongoDatabase = getEnvOrDefault("MONGO_DATABASE", "billai")
|
||||||
|
Global.MongoRawCollection = getEnvOrDefault("MONGO_RAW_COLLECTION", "bills_raw")
|
||||||
|
Global.MongoCleanedCollection = getEnvOrDefault("MONGO_CLEANED_COLLECTION", "bills_cleaned")
|
||||||
|
|
||||||
// 查找配置文件
|
// 查找配置文件
|
||||||
configPath := configFilePath
|
configPath := configFilePath
|
||||||
if !filepath.IsAbs(configPath) {
|
if !filepath.IsAbs(configPath) {
|
||||||
@@ -128,6 +148,19 @@ func Load() {
|
|||||||
if cfg.Directories.Output != "" {
|
if cfg.Directories.Output != "" {
|
||||||
Global.OutputDir = cfg.Directories.Output
|
Global.OutputDir = cfg.Directories.Output
|
||||||
}
|
}
|
||||||
|
// MongoDB 配置
|
||||||
|
if cfg.MongoDB.URI != "" {
|
||||||
|
Global.MongoURI = cfg.MongoDB.URI
|
||||||
|
}
|
||||||
|
if cfg.MongoDB.Database != "" {
|
||||||
|
Global.MongoDatabase = cfg.MongoDB.Database
|
||||||
|
}
|
||||||
|
if cfg.MongoDB.Collections.Raw != "" {
|
||||||
|
Global.MongoRawCollection = cfg.MongoDB.Collections.Raw
|
||||||
|
}
|
||||||
|
if cfg.MongoDB.Collections.Cleaned != "" {
|
||||||
|
Global.MongoCleanedCollection = cfg.MongoDB.Collections.Cleaned
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 环境变量覆盖
|
// 环境变量覆盖
|
||||||
@@ -140,6 +173,19 @@ func Load() {
|
|||||||
if root := os.Getenv("BILLAI_ROOT"); root != "" {
|
if root := os.Getenv("BILLAI_ROOT"); root != "" {
|
||||||
Global.ProjectRoot = root
|
Global.ProjectRoot = root
|
||||||
}
|
}
|
||||||
|
// MongoDB 环境变量覆盖
|
||||||
|
if uri := os.Getenv("MONGO_URI"); uri != "" {
|
||||||
|
Global.MongoURI = uri
|
||||||
|
}
|
||||||
|
if db := os.Getenv("MONGO_DATABASE"); db != "" {
|
||||||
|
Global.MongoDatabase = db
|
||||||
|
}
|
||||||
|
if rawColl := os.Getenv("MONGO_RAW_COLLECTION"); rawColl != "" {
|
||||||
|
Global.MongoRawCollection = rawColl
|
||||||
|
}
|
||||||
|
if cleanedColl := os.Getenv("MONGO_CLEANED_COLLECTION"); cleanedColl != "" {
|
||||||
|
Global.MongoCleanedCollection = cleanedColl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvePath 解析路径(相对路径转为绝对路径)
|
// ResolvePath 解析路径(相对路径转为绝对路径)
|
||||||
|
|||||||
72
server/database/mongo.go
Normal file
72
server/database/mongo.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
|
||||||
|
"billai-server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Client MongoDB 客户端实例
|
||||||
|
Client *mongo.Client
|
||||||
|
// DB 数据库实例
|
||||||
|
DB *mongo.Database
|
||||||
|
// RawBillCollection 原始账单数据集合
|
||||||
|
RawBillCollection *mongo.Collection
|
||||||
|
// CleanedBillCollection 清洗后账单数据集合
|
||||||
|
CleanedBillCollection *mongo.Collection
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connect 连接 MongoDB
|
||||||
|
func Connect() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 创建客户端选项
|
||||||
|
clientOptions := options.Client().ApplyURI(config.Global.MongoURI)
|
||||||
|
|
||||||
|
// 连接 MongoDB
|
||||||
|
client, err := mongo.Connect(ctx, clientOptions)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("连接 MongoDB 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
if err := client.Ping(ctx, nil); err != nil {
|
||||||
|
return fmt.Errorf("MongoDB Ping 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置全局变量
|
||||||
|
Client = client
|
||||||
|
DB = client.Database(config.Global.MongoDatabase)
|
||||||
|
RawBillCollection = DB.Collection(config.Global.MongoRawCollection)
|
||||||
|
CleanedBillCollection = DB.Collection(config.Global.MongoCleanedCollection)
|
||||||
|
|
||||||
|
fmt.Printf("🍃 MongoDB 连接成功: %s\n", config.Global.MongoDatabase)
|
||||||
|
fmt.Printf(" 📄 原始数据集合: %s\n", config.Global.MongoRawCollection)
|
||||||
|
fmt.Printf(" 📄 清洗数据集合: %s\n", config.Global.MongoCleanedCollection)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect 断开 MongoDB 连接
|
||||||
|
func Disconnect() error {
|
||||||
|
if Client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := Client.Disconnect(ctx); err != nil {
|
||||||
|
return fmt.Errorf("断开 MongoDB 连接失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("🍃 MongoDB 连接已断开")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ go 1.21
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
go.mongodb.org/mongo-driver v1.13.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,18 +17,26 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/golang/snappy v0.0.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.13.6 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/crypto v0.9.0 // indirect
|
golang.org/x/crypto v0.9.0 // indirect
|
||||||
golang.org/x/net v0.10.0 // indirect
|
golang.org/x/net v0.10.0 // indirect
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
|
||||||
golang.org/x/sys v0.8.0 // indirect
|
golang.org/x/sys v0.8.0 // indirect
|
||||||
golang.org/x/text v0.9.0 // indirect
|
golang.org/x/text v0.9.0 // indirect
|
||||||
google.golang.org/protobuf v1.30.0 // indirect
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
|
|||||||
@@ -24,11 +24,16 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX
|
|||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
|
||||||
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
@@ -41,6 +46,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0=
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -60,19 +67,59 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk=
|
||||||
|
go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"billai-server/config"
|
"billai-server/config"
|
||||||
"billai-server/model"
|
"billai-server/model"
|
||||||
|
"billai-server/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Upload 处理账单上传和清理请求
|
// Upload 处理账单上传和清理请求
|
||||||
@@ -53,7 +53,45 @@ func Upload(c *gin.Context) {
|
|||||||
defer dst.Close()
|
defer dst.Close()
|
||||||
io.Copy(dst, file)
|
io.Copy(dst, file)
|
||||||
|
|
||||||
// 4. 构建输出文件路径
|
// 4. 对原始数据进行去重检查
|
||||||
|
fmt.Printf("📋 开始去重检查...\n")
|
||||||
|
dedupResult, dedupErr := service.DeduplicateRawFile(inputPath, timestamp)
|
||||||
|
if dedupErr != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
||||||
|
Result: false,
|
||||||
|
Message: "去重检查失败: " + dedupErr.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账单类型从去重结果获取
|
||||||
|
billType := dedupResult.BillType
|
||||||
|
|
||||||
|
fmt.Printf(" 原始记录: %d 条\n", dedupResult.OriginalCount)
|
||||||
|
if dedupResult.DuplicateCount > 0 {
|
||||||
|
fmt.Printf(" 重复记录: %d 条(已跳过)\n", dedupResult.DuplicateCount)
|
||||||
|
}
|
||||||
|
fmt.Printf(" 新增记录: %d 条\n", dedupResult.NewCount)
|
||||||
|
|
||||||
|
// 如果全部重复,返回提示
|
||||||
|
if dedupResult.NewCount == 0 {
|
||||||
|
c.JSON(http.StatusOK, model.UploadResponse{
|
||||||
|
Result: true,
|
||||||
|
Message: fmt.Sprintf("文件中的 %d 条记录全部已存在,无需重复导入", dedupResult.OriginalCount),
|
||||||
|
Data: &model.UploadData{
|
||||||
|
BillType: billType,
|
||||||
|
RawCount: 0,
|
||||||
|
CleanedCount: 0,
|
||||||
|
DuplicateCount: dedupResult.DuplicateCount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用去重后的文件路径进行后续处理
|
||||||
|
processFilePath := dedupResult.DedupFilePath
|
||||||
|
|
||||||
|
// 5. 构建输出文件路径
|
||||||
baseName := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
baseName := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
||||||
outputExt := ".csv"
|
outputExt := ".csv"
|
||||||
if req.Format == "json" {
|
if req.Format == "json" {
|
||||||
@@ -63,57 +101,65 @@ func Upload(c *gin.Context) {
|
|||||||
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
|
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
|
||||||
outputPath := filepath.Join(outputDirAbs, outputFileName)
|
outputPath := filepath.Join(outputDirAbs, outputFileName)
|
||||||
|
|
||||||
// 5. 构建命令参数
|
// 6. 执行 Python 清洗脚本
|
||||||
cleanScriptAbs := config.ResolvePath(config.Global.CleanScript)
|
cleanOpts := &service.CleanOptions{
|
||||||
args := []string{cleanScriptAbs, inputPath, outputPath}
|
Year: req.Year,
|
||||||
if req.Year != "" {
|
Month: req.Month,
|
||||||
args = append(args, "--year", req.Year)
|
Start: req.Start,
|
||||||
|
End: req.End,
|
||||||
|
Format: req.Format,
|
||||||
}
|
}
|
||||||
if req.Month != "" {
|
cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
|
||||||
args = append(args, "--month", req.Month)
|
if cleanErr != nil {
|
||||||
}
|
|
||||||
if req.Start != "" {
|
|
||||||
args = append(args, "--start", req.Start)
|
|
||||||
}
|
|
||||||
if req.End != "" {
|
|
||||||
args = append(args, "--end", req.End)
|
|
||||||
}
|
|
||||||
if req.Format != "" {
|
|
||||||
args = append(args, "--format", req.Format)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 执行 Python 脚本
|
|
||||||
pythonPathAbs := config.ResolvePath(config.Global.PythonPath)
|
|
||||||
cmd := exec.Command(pythonPathAbs, args...)
|
|
||||||
cmd.Dir = config.Global.ProjectRoot
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
outputStr := string(output)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
||||||
Result: false,
|
Result: false,
|
||||||
Message: "处理失败: " + err.Error(),
|
Message: cleanErr.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 检测账单类型
|
// 7. 如果去重检测没有识别出类型,从 Python 输出中检测
|
||||||
billType := ""
|
if billType == "" {
|
||||||
if strings.Contains(outputStr, "支付宝") {
|
billType = cleanResult.BillType
|
||||||
billType = "alipay"
|
}
|
||||||
} else if strings.Contains(outputStr, "微信") {
|
|
||||||
billType = "wechat"
|
// 8. 将去重后的原始数据存入 MongoDB(原始数据集合)
|
||||||
|
rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp)
|
||||||
|
if rawErr != nil {
|
||||||
|
fmt.Printf("⚠️ 存储原始数据到 MongoDB 失败: %v\n", rawErr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✅ 已存储 %d 条原始账单记录到 MongoDB\n", rawCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 将清洗后的数据存入 MongoDB(清洗后数据集合)
|
||||||
|
cleanedCount, _, cleanedErr := service.SaveCleanedBillsFromFile(outputPath, req.Format, billType, header.Filename, timestamp)
|
||||||
|
if cleanedErr != nil {
|
||||||
|
fmt.Printf("⚠️ 存储清洗后数据到 MongoDB 失败: %v\n", cleanedErr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✅ 已存储 %d 条清洗后账单记录到 MongoDB\n", cleanedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. 清理临时的去重文件(如果生成了的话)
|
||||||
|
if dedupResult.DedupFilePath != inputPath && dedupResult.DedupFilePath != "" {
|
||||||
|
os.Remove(dedupResult.DedupFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. 返回成功响应
|
||||||
|
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
|
||||||
|
if dedupResult.DuplicateCount > 0 {
|
||||||
|
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 返回成功响应
|
|
||||||
c.JSON(http.StatusOK, model.UploadResponse{
|
c.JSON(http.StatusOK, model.UploadResponse{
|
||||||
Result: true,
|
Result: true,
|
||||||
Message: "处理成功",
|
Message: message,
|
||||||
Data: &model.UploadData{
|
Data: &model.UploadData{
|
||||||
BillType: billType,
|
BillType: billType,
|
||||||
FileURL: fmt.Sprintf("/download/%s", outputFileName),
|
FileURL: fmt.Sprintf("/download/%s", outputFileName),
|
||||||
FileName: outputFileName,
|
FileName: outputFileName,
|
||||||
|
RawCount: rawCount,
|
||||||
|
CleanedCount: cleanedCount,
|
||||||
|
DuplicateCount: dedupResult.DuplicateCount,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"billai-server/config"
|
"billai-server/config"
|
||||||
|
"billai-server/database"
|
||||||
"billai-server/handler"
|
"billai-server/handler"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,12 +36,32 @@ func main() {
|
|||||||
fmt.Println(" 请在配置文件中指定正确的 Python 路径")
|
fmt.Println(" 请在配置文件中指定正确的 Python 路径")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 连接 MongoDB
|
||||||
|
if err := database.Connect(); err != nil {
|
||||||
|
fmt.Printf("⚠️ 警告: MongoDB 连接失败: %v\n", err)
|
||||||
|
fmt.Println(" 账单数据将不会存储到数据库")
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
// 优雅关闭时断开连接
|
||||||
|
defer database.Disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
// 创建路由
|
// 创建路由
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
// 注册路由
|
// 注册路由
|
||||||
setupRoutes(r, outputDirAbs, pythonPathAbs)
|
setupRoutes(r, outputDirAbs, pythonPathAbs)
|
||||||
|
|
||||||
|
// 监听系统信号
|
||||||
|
go func() {
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
fmt.Println("\n🛑 正在关闭服务...")
|
||||||
|
database.Disconnect()
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
// 启动服务
|
// 启动服务
|
||||||
printAPIInfo()
|
printAPIInfo()
|
||||||
r.Run(":" + config.Global.Port)
|
r.Run(":" + config.Global.Port)
|
||||||
@@ -74,6 +97,7 @@ func printBanner(pythonPath, uploadDir, outputDir string) {
|
|||||||
fmt.Printf("🐍 Python路径: %s\n", pythonPath)
|
fmt.Printf("🐍 Python路径: %s\n", pythonPath)
|
||||||
fmt.Printf("📂 上传目录: %s\n", uploadDir)
|
fmt.Printf("📂 上传目录: %s\n", uploadDir)
|
||||||
fmt.Printf("📂 输出目录: %s\n", outputDir)
|
fmt.Printf("📂 输出目录: %s\n", outputDir)
|
||||||
|
fmt.Printf("🍃 MongoDB: %s/%s\n", config.Global.MongoURI, config.Global.MongoDatabase)
|
||||||
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
server/model/bill.go
Normal file
40
server/model/bill.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RawBill 原始账单记录(存储上传的原始数据)
|
||||||
|
type RawBill struct {
|
||||||
|
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
||||||
|
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
|
||||||
|
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
||||||
|
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
|
||||||
|
RowIndex int `bson:"row_index" json:"row_index"` // 原始行号
|
||||||
|
RawData map[string]interface{} `bson:"raw_data" json:"raw_data"` // 原始字段数据
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"` // 创建时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanedBill 清洗后账单记录(标准化后的数据)
|
||||||
|
type CleanedBill struct {
|
||||||
|
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
||||||
|
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
|
||||||
|
TransactionID string `bson:"transaction_id" json:"transaction_id"` // 交易订单号(用于去重)
|
||||||
|
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"` // 商家订单号(用于去重)
|
||||||
|
Time time.Time `bson:"time" json:"time"` // 交易时间
|
||||||
|
Category string `bson:"category" json:"category"` // 交易分类
|
||||||
|
Merchant string `bson:"merchant" json:"merchant"` // 交易对方
|
||||||
|
Description string `bson:"description" json:"description"` // 商品说明
|
||||||
|
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
|
||||||
|
Amount float64 `bson:"amount" json:"amount"` // 金额
|
||||||
|
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
|
||||||
|
Status string `bson:"status" json:"status"` // 交易状态
|
||||||
|
Remark string `bson:"remark" json:"remark"` // 备注
|
||||||
|
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"` // 创建时间
|
||||||
|
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` // 更新时间
|
||||||
|
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
||||||
|
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ type UploadData struct {
|
|||||||
BillType string `json:"bill_type,omitempty"` // alipay/wechat
|
BillType string `json:"bill_type,omitempty"` // alipay/wechat
|
||||||
FileURL string `json:"file_url,omitempty"` // 下载链接
|
FileURL string `json:"file_url,omitempty"` // 下载链接
|
||||||
FileName string `json:"file_name,omitempty"` // 文件名
|
FileName string `json:"file_name,omitempty"` // 文件名
|
||||||
|
RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数
|
||||||
|
CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数
|
||||||
|
DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResponse 上传响应
|
// UploadResponse 上传响应
|
||||||
|
|||||||
621
server/service/bill.go
Normal file
621
server/service/bill.go
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
|
||||||
|
"billai-server/database"
|
||||||
|
"billai-server/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SaveResult 存储结果
|
||||||
|
type SaveResult struct {
|
||||||
|
RawCount int // 原始数据存储数量
|
||||||
|
CleanedCount int // 清洗后数据存储数量
|
||||||
|
DuplicateCount int // 重复数据跳过数量
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDuplicate 检查记录是否重复
|
||||||
|
// 优先使用 transaction_id 判断,如果为空则使用 时间+金额+商户 组合判断
|
||||||
|
func checkDuplicate(ctx context.Context, bill *model.CleanedBill) bool {
|
||||||
|
var filter bson.M
|
||||||
|
|
||||||
|
if bill.TransactionID != "" {
|
||||||
|
// 优先用交易订单号判断
|
||||||
|
filter = bson.M{"transaction_id": bill.TransactionID}
|
||||||
|
} else {
|
||||||
|
// 回退到 时间+金额+商户 组合判断
|
||||||
|
filter = bson.M{
|
||||||
|
"time": bill.Time,
|
||||||
|
"amount": bill.Amount,
|
||||||
|
"merchant": bill.Merchant,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := database.CleanedBillCollection.CountDocuments(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return false // 查询出错时不认为是重复
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeduplicateResult 去重结果
|
||||||
|
type DeduplicateResult struct {
|
||||||
|
OriginalCount int // 原始记录数
|
||||||
|
DuplicateCount int // 重复记录数
|
||||||
|
NewCount int // 新记录数
|
||||||
|
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
|
||||||
|
BillType string // 检测到的账单类型
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径
|
||||||
|
// 如果全部重复,返回错误
|
||||||
|
func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
reader := csv.NewReader(file)
|
||||||
|
rows, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 CSV 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) < 2 {
|
||||||
|
return nil, fmt.Errorf("文件没有数据行")
|
||||||
|
}
|
||||||
|
|
||||||
|
header := rows[0]
|
||||||
|
dataRows := rows[1:]
|
||||||
|
|
||||||
|
// 检测账单类型和去重字段
|
||||||
|
billType, idFieldIdx := detectBillTypeAndIdField(header)
|
||||||
|
|
||||||
|
result := &DeduplicateResult{
|
||||||
|
OriginalCount: len(dataRows),
|
||||||
|
BillType: billType,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果找不到去重字段,不进行去重,直接返回原文件
|
||||||
|
if idFieldIdx < 0 {
|
||||||
|
result.NewCount = len(dataRows)
|
||||||
|
result.DedupFilePath = filePath
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建上下文
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 检查每行是否重复
|
||||||
|
var newRows [][]string
|
||||||
|
for _, row := range dataRows {
|
||||||
|
if len(row) <= idFieldIdx {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionID := strings.TrimSpace(row[idFieldIdx])
|
||||||
|
if transactionID == "" {
|
||||||
|
// 没有交易号的行,保留
|
||||||
|
newRows = append(newRows, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
count, err := database.RawBillCollection.CountDocuments(ctx, bson.M{
|
||||||
|
"raw_data." + header[idFieldIdx]: transactionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// 查询出错,保留该行
|
||||||
|
newRows = append(newRows, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
// 不重复,保留
|
||||||
|
newRows = append(newRows, row)
|
||||||
|
} else {
|
||||||
|
result.DuplicateCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.NewCount = len(newRows)
|
||||||
|
|
||||||
|
// 如果没有新数据
|
||||||
|
if len(newRows) == 0 {
|
||||||
|
result.DedupFilePath = ""
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有重复,直接返回原文件
|
||||||
|
if result.DuplicateCount == 0 {
|
||||||
|
result.DedupFilePath = filePath
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有重复,生成去重后的新文件
|
||||||
|
dedupFilePath := strings.TrimSuffix(filePath, ".csv") + "_dedup.csv"
|
||||||
|
dedupFile, err := os.Create(dedupFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建去重文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer dedupFile.Close()
|
||||||
|
|
||||||
|
writer := csv.NewWriter(dedupFile)
|
||||||
|
writer.Write(header) // 写入表头
|
||||||
|
for _, row := range newRows {
|
||||||
|
writer.Write(row)
|
||||||
|
}
|
||||||
|
writer.Flush()
|
||||||
|
|
||||||
|
result.DedupFilePath = dedupFilePath
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectBillTypeAndIdField 检测账单类型和用于去重的字段索引
|
||||||
|
func detectBillTypeAndIdField(header []string) (billType string, idFieldIdx int) {
|
||||||
|
idFieldIdx = -1
|
||||||
|
|
||||||
|
for i, col := range header {
|
||||||
|
// 支付宝特征
|
||||||
|
if col == "交易分类" || col == "对方账号" {
|
||||||
|
billType = "alipay"
|
||||||
|
}
|
||||||
|
// 微信特征
|
||||||
|
if col == "交易类型" || col == "金额(元)" {
|
||||||
|
billType = "wechat"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找去重字段(优先使用交易订单号/交易号)
|
||||||
|
if col == "交易订单号" || col == "交易号" || col == "交易单号" {
|
||||||
|
idFieldIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没找到主要去重字段,尝试商户订单号
|
||||||
|
if idFieldIdx < 0 {
|
||||||
|
for i, col := range header {
|
||||||
|
if col == "商家订单号" || col == "商户单号" || col == "商户订单号" {
|
||||||
|
idFieldIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return billType, idFieldIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRawBillsFromFile 从原始上传文件读取数据并存入原始数据集合
|
||||||
|
func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (int, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("打开文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
reader := csv.NewReader(file)
|
||||||
|
rows, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("读取 CSV 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) < 2 {
|
||||||
|
return 0, nil // 没有数据行
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取表头
|
||||||
|
header := rows[0]
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// 构建原始数据文档
|
||||||
|
var rawBills []interface{}
|
||||||
|
for rowIdx, row := range rows[1:] {
|
||||||
|
rawData := make(map[string]interface{})
|
||||||
|
for colIdx, col := range header {
|
||||||
|
if colIdx < len(row) {
|
||||||
|
// 清理空白字符,确保去重查询能匹配
|
||||||
|
rawData[col] = strings.TrimSpace(row[colIdx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawBill := model.RawBill{
|
||||||
|
BillType: billType,
|
||||||
|
SourceFile: sourceFile,
|
||||||
|
UploadBatch: uploadBatch,
|
||||||
|
RowIndex: rowIdx + 1, // 从1开始计数
|
||||||
|
RawData: rawData,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
rawBills = append(rawBills, rawBill)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rawBills) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入原始数据集合
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := database.RawBillCollection.InsertMany(ctx, rawBills)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("插入原始数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(result.InsertedIDs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCleanedBillsFromFile 从清洗后的文件读取数据并存入清洗后数据集合
|
||||||
|
// 返回: (插入数量, 重复跳过数量, 错误)
|
||||||
|
func SaveCleanedBillsFromFile(filePath, format, billType, sourceFile, uploadBatch string) (int, int, error) {
|
||||||
|
if format == "json" {
|
||||||
|
return saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch)
|
||||||
|
}
|
||||||
|
return saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveCleanedBillsFromCSV 从 CSV 文件读取并存储清洗后账单
|
||||||
|
// 返回: (插入数量, 重复跳过数量, 错误)
|
||||||
|
func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string) (int, int, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("打开文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
reader := csv.NewReader(file)
|
||||||
|
rows, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("读取 CSV 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) < 2 {
|
||||||
|
return 0, 0, nil // 没有数据行
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建列索引映射
|
||||||
|
header := rows[0]
|
||||||
|
colIdx := make(map[string]int)
|
||||||
|
for i, col := range header {
|
||||||
|
colIdx[col] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建上下文用于去重检查
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 解析数据行
|
||||||
|
var bills []interface{}
|
||||||
|
duplicateCount := 0
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, row := range rows[1:] {
|
||||||
|
bill := model.CleanedBill{
|
||||||
|
BillType: billType,
|
||||||
|
SourceFile: sourceFile,
|
||||||
|
UploadBatch: uploadBatch,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取字段 - 订单号(用于去重判断)
|
||||||
|
if idx, ok := colIdx["交易订单号"]; ok && len(row) > idx {
|
||||||
|
bill.TransactionID = strings.TrimSpace(row[idx])
|
||||||
|
} else if idx, ok := colIdx["交易号"]; ok && len(row) > idx {
|
||||||
|
bill.TransactionID = strings.TrimSpace(row[idx])
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["商家订单号"]; ok && len(row) > idx {
|
||||||
|
bill.MerchantOrderNo = strings.TrimSpace(row[idx])
|
||||||
|
} else if idx, ok := colIdx["商户单号"]; ok && len(row) > idx {
|
||||||
|
bill.MerchantOrderNo = strings.TrimSpace(row[idx])
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["交易时间"]; ok && len(row) > idx {
|
||||||
|
bill.Time = parseTime(row[idx])
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["交易分类"]; ok && len(row) > idx {
|
||||||
|
bill.Category = row[idx]
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["交易对方"]; ok && len(row) > idx {
|
||||||
|
bill.Merchant = row[idx]
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["商品说明"]; ok && len(row) > idx {
|
||||||
|
bill.Description = row[idx]
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["收/支"]; ok && len(row) > idx {
|
||||||
|
bill.IncomeExpense = row[idx]
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["金额"]; ok && len(row) > idx {
|
||||||
|
bill.Amount = parseAmount(row[idx])
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["支付方式"]; ok && len(row) > idx {
|
||||||
|
bill.PayMethod = row[idx]
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["交易状态"]; ok && len(row) > idx {
|
||||||
|
bill.Status = row[idx]
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["备注"]; ok && len(row) > idx {
|
||||||
|
bill.Remark = row[idx]
|
||||||
|
}
|
||||||
|
if idx, ok := colIdx["复核等级"]; ok && len(row) > idx {
|
||||||
|
bill.ReviewLevel = row[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否重复
|
||||||
|
if checkDuplicate(ctx, &bill) {
|
||||||
|
duplicateCount++
|
||||||
|
continue // 跳过重复记录
|
||||||
|
}
|
||||||
|
|
||||||
|
bills = append(bills, bill)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bills) == 0 {
|
||||||
|
return 0, duplicateCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入清洗后数据集合
|
||||||
|
result, err := database.CleanedBillCollection.InsertMany(ctx, bills)
|
||||||
|
if err != nil {
|
||||||
|
return 0, duplicateCount, fmt.Errorf("插入清洗后数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(result.InsertedIDs), duplicateCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveCleanedBillsFromJSON 从 JSON 文件读取并存储清洗后账单
|
||||||
|
// 返回: (插入数量, 重复跳过数量, 错误)
|
||||||
|
func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string) (int, int, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("打开文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var data []map[string]interface{}
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
if err := decoder.Decode(&data); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("解析 JSON 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建上下文用于去重检查
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 解析数据
|
||||||
|
var bills []interface{}
|
||||||
|
duplicateCount := 0
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, item := range data {
|
||||||
|
bill := model.CleanedBill{
|
||||||
|
BillType: billType,
|
||||||
|
SourceFile: sourceFile,
|
||||||
|
UploadBatch: uploadBatch,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单号(用于去重判断)
|
||||||
|
if v, ok := item["交易订单号"].(string); ok {
|
||||||
|
bill.TransactionID = strings.TrimSpace(v)
|
||||||
|
} else if v, ok := item["交易号"].(string); ok {
|
||||||
|
bill.TransactionID = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if v, ok := item["商家订单号"].(string); ok {
|
||||||
|
bill.MerchantOrderNo = strings.TrimSpace(v)
|
||||||
|
} else if v, ok := item["商户单号"].(string); ok {
|
||||||
|
bill.MerchantOrderNo = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if v, ok := item["交易时间"].(string); ok {
|
||||||
|
bill.Time = parseTime(v)
|
||||||
|
}
|
||||||
|
if v, ok := item["交易分类"].(string); ok {
|
||||||
|
bill.Category = v
|
||||||
|
}
|
||||||
|
if v, ok := item["交易对方"].(string); ok {
|
||||||
|
bill.Merchant = v
|
||||||
|
}
|
||||||
|
if v, ok := item["商品说明"].(string); ok {
|
||||||
|
bill.Description = v
|
||||||
|
}
|
||||||
|
if v, ok := item["收/支"].(string); ok {
|
||||||
|
bill.IncomeExpense = v
|
||||||
|
}
|
||||||
|
if v, ok := item["金额"]; ok {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
bill.Amount = parseAmount(val)
|
||||||
|
case float64:
|
||||||
|
bill.Amount = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := item["支付方式"].(string); ok {
|
||||||
|
bill.PayMethod = v
|
||||||
|
}
|
||||||
|
if v, ok := item["交易状态"].(string); ok {
|
||||||
|
bill.Status = v
|
||||||
|
}
|
||||||
|
if v, ok := item["备注"].(string); ok {
|
||||||
|
bill.Remark = v
|
||||||
|
}
|
||||||
|
if v, ok := item["复核等级"].(string); ok {
|
||||||
|
bill.ReviewLevel = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否重复
|
||||||
|
if checkDuplicate(ctx, &bill) {
|
||||||
|
duplicateCount++
|
||||||
|
continue // 跳过重复记录
|
||||||
|
}
|
||||||
|
|
||||||
|
bills = append(bills, bill)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bills) == 0 {
|
||||||
|
return 0, duplicateCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := database.CleanedBillCollection.InsertMany(ctx, bills)
|
||||||
|
if err != nil {
|
||||||
|
return 0, duplicateCount, fmt.Errorf("插入清洗后数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(result.InsertedIDs), duplicateCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTime 解析时间字符串
|
||||||
|
func parseTime(s string) time.Time {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试多种时间格式
|
||||||
|
formats := []string{
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006/01/02 15:04:05",
|
||||||
|
"2006-01-02 15:04",
|
||||||
|
"2006/01/02 15:04",
|
||||||
|
"2006-01-02",
|
||||||
|
"2006/01/02",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, format := range formats {
|
||||||
|
if t, err := time.Parse(format, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAmount 解析金额字符串
|
||||||
|
func parseAmount(s string) float64 {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.ReplaceAll(s, ",", "")
|
||||||
|
s = strings.ReplaceAll(s, "¥", "")
|
||||||
|
s = strings.ReplaceAll(s, "¥", "")
|
||||||
|
|
||||||
|
if amount, err := strconv.ParseFloat(s, 64); err == nil {
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCleanedBillsByBatch 根据批次获取清洗后账单
|
||||||
|
func GetCleanedBillsByBatch(uploadBatch string) ([]model.CleanedBill, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cursor, err := database.CleanedBillCollection.Find(ctx, bson.M{"upload_batch": uploadBatch})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询失败: %w", err)
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var bills []model.CleanedBill
|
||||||
|
if err := cursor.All(ctx, &bills); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析结果失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bills, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRawBillsByBatch 根据批次获取原始账单
|
||||||
|
func GetRawBillsByBatch(uploadBatch string) ([]model.RawBill, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cursor, err := database.RawBillCollection.Find(ctx, bson.M{"upload_batch": uploadBatch})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询失败: %w", err)
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var bills []model.RawBill
|
||||||
|
if err := cursor.All(ctx, &bills); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析结果失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bills, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBillStats 获取账单统计信息
|
||||||
|
func GetBillStats() (map[string]interface{}, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 原始数据总数
|
||||||
|
rawTotal, err := database.RawBillCollection.CountDocuments(ctx, bson.M{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清洗后数据总数
|
||||||
|
cleanedTotal, err := database.CleanedBillCollection.CountDocuments(ctx, bson.M{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支出总额(从清洗后数据统计)
|
||||||
|
expensePipeline := []bson.M{
|
||||||
|
{"$match": bson.M{"income_expense": "支出"}},
|
||||||
|
{"$group": bson.M{"_id": nil, "total": bson.M{"$sum": "$amount"}}},
|
||||||
|
}
|
||||||
|
expenseCursor, err := database.CleanedBillCollection.Aggregate(ctx, expensePipeline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer expenseCursor.Close(ctx)
|
||||||
|
|
||||||
|
var expenseResult []bson.M
|
||||||
|
expenseCursor.All(ctx, &expenseResult)
|
||||||
|
totalExpense := 0.0
|
||||||
|
if len(expenseResult) > 0 {
|
||||||
|
if v, ok := expenseResult[0]["total"].(float64); ok {
|
||||||
|
totalExpense = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收入总额(从清洗后数据统计)
|
||||||
|
incomePipeline := []bson.M{
|
||||||
|
{"$match": bson.M{"income_expense": "收入"}},
|
||||||
|
{"$group": bson.M{"_id": nil, "total": bson.M{"$sum": "$amount"}}},
|
||||||
|
}
|
||||||
|
incomeCursor, err := database.CleanedBillCollection.Aggregate(ctx, incomePipeline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer incomeCursor.Close(ctx)
|
||||||
|
|
||||||
|
var incomeResult []bson.M
|
||||||
|
incomeCursor.All(ctx, &incomeResult)
|
||||||
|
totalIncome := 0.0
|
||||||
|
if len(incomeResult) > 0 {
|
||||||
|
if v, ok := incomeResult[0]["total"].(float64); ok {
|
||||||
|
totalIncome = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"raw_records": rawTotal,
|
||||||
|
"cleaned_records": cleanedTotal,
|
||||||
|
"total_expense": totalExpense,
|
||||||
|
"total_income": totalIncome,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
84
server/service/cleaner.go
Normal file
84
server/service/cleaner.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"billai-server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CleanOptions 清洗选项
|
||||||
|
type CleanOptions struct {
|
||||||
|
Year string // 年份筛选
|
||||||
|
Month string // 月份筛选
|
||||||
|
Start string // 起始日期
|
||||||
|
End string // 结束日期
|
||||||
|
Format string // 输出格式: csv/json
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanResult 清洗结果
|
||||||
|
type CleanResult struct {
|
||||||
|
BillType string // 检测到的账单类型: alipay/wechat
|
||||||
|
Output string // Python 脚本输出
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCleanScript 执行 Python 清洗脚本
|
||||||
|
// inputPath: 输入文件路径
|
||||||
|
// outputPath: 输出文件路径
|
||||||
|
// opts: 清洗选项
|
||||||
|
func RunCleanScript(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error) {
|
||||||
|
// 构建命令参数
|
||||||
|
cleanScriptAbs := config.ResolvePath(config.Global.CleanScript)
|
||||||
|
args := []string{cleanScriptAbs, inputPath, outputPath}
|
||||||
|
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Year != "" {
|
||||||
|
args = append(args, "--year", opts.Year)
|
||||||
|
}
|
||||||
|
if opts.Month != "" {
|
||||||
|
args = append(args, "--month", opts.Month)
|
||||||
|
}
|
||||||
|
if opts.Start != "" {
|
||||||
|
args = append(args, "--start", opts.Start)
|
||||||
|
}
|
||||||
|
if opts.End != "" {
|
||||||
|
args = append(args, "--end", opts.End)
|
||||||
|
}
|
||||||
|
if opts.Format != "" {
|
||||||
|
args = append(args, "--format", opts.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 Python 脚本
|
||||||
|
fmt.Printf("🐍 执行清洗脚本...\n")
|
||||||
|
pythonPathAbs := config.ResolvePath(config.Global.PythonPath)
|
||||||
|
cmd := exec.Command(pythonPathAbs, args...)
|
||||||
|
cmd.Dir = config.Global.ProjectRoot
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("清洗脚本执行失败: %w\n输出: %s", err, outputStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从输出中检测账单类型
|
||||||
|
billType := DetectBillTypeFromOutput(outputStr)
|
||||||
|
|
||||||
|
return &CleanResult{
|
||||||
|
BillType: billType,
|
||||||
|
Output: outputStr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectBillTypeFromOutput 从 Python 脚本输出中检测账单类型
|
||||||
|
func DetectBillTypeFromOutput(output string) string {
|
||||||
|
if strings.Contains(output, "支付宝") {
|
||||||
|
return "alipay"
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "微信") {
|
||||||
|
return "wechat"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
67
web/src/lib/config/theme.ts
Normal file
67
web/src/lib/config/theme.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import Monitor from '@lucide/svelte/icons/monitor';
|
||||||
|
import Sun from '@lucide/svelte/icons/sun';
|
||||||
|
import Moon from '@lucide/svelte/icons/moon';
|
||||||
|
import type { ComponentType } from 'svelte';
|
||||||
|
|
||||||
|
export type ThemeMode = 'system' | 'light' | 'dark';
|
||||||
|
|
||||||
|
export interface ThemeOption {
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeConfig: Record<ThemeMode, ThemeOption> = {
|
||||||
|
system: { label: '跟随系统', icon: Monitor },
|
||||||
|
light: { label: '浅色模式', icon: Sun },
|
||||||
|
dark: { label: '深色模式', icon: Moon }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const themeCycle: ThemeMode[] = ['system', 'light', 'dark'];
|
||||||
|
|
||||||
|
export const THEME_STORAGE_KEY = 'theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取下一个主题模式
|
||||||
|
*/
|
||||||
|
export function getNextTheme(current: ThemeMode): ThemeMode {
|
||||||
|
const currentIndex = themeCycle.indexOf(current);
|
||||||
|
return themeCycle[(currentIndex + 1) % themeCycle.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前是否应该使用深色模式
|
||||||
|
*/
|
||||||
|
export function shouldUseDarkMode(mode: ThemeMode): boolean {
|
||||||
|
if (mode === 'system') {
|
||||||
|
return typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
: false;
|
||||||
|
}
|
||||||
|
return mode === 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用主题到 document
|
||||||
|
*/
|
||||||
|
export function applyThemeToDocument(mode: ThemeMode): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
document.documentElement.classList.toggle('dark', shouldUseDarkMode(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 localStorage 读取保存的主题
|
||||||
|
*/
|
||||||
|
export function loadThemeFromStorage(): ThemeMode {
|
||||||
|
if (typeof localStorage === 'undefined') return 'system';
|
||||||
|
const saved = localStorage.getItem(THEME_STORAGE_KEY) as ThemeMode | null;
|
||||||
|
return saved && themeCycle.includes(saved) ? saved : 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存主题到 localStorage
|
||||||
|
*/
|
||||||
|
export function saveThemeToStorage(mode: ThemeMode): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,198 +1,169 @@
|
|||||||
import type { BillRecord } from '$lib/api';
|
import type { BillRecord } from '$lib/api';
|
||||||
|
|
||||||
// 生成随机金额
|
/**
|
||||||
function randomAmount(min: number, max: number): string {
|
* 真实账单数据(来自支付宝和微信导出)
|
||||||
return (Math.random() * (max - min) + min).toFixed(2);
|
* 数据已脱敏处理
|
||||||
}
|
*/
|
||||||
|
export const demoRecords: BillRecord[] = [
|
||||||
|
// ========== 支付宝数据 ==========
|
||||||
|
{ 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 11:55:10", category: "餐饮美食", merchant: "金山武汉食堂", description: "小碗菜", income_expense: "支出", amount: "12.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-06 09:35:09", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "16.09", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-05 18:59:11", category: "餐饮美食", merchant: "板栗", description: "收钱码收款", income_expense: "支出", amount: "21.00", payment_method: "花呗", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-05 18:22:34", category: "服饰装扮", merchant: "金山便利店", description: "立码收收款", income_expense: "支出", amount: "40.69", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-05 15:16:38", category: "充值缴费", merchant: "武汉供电公司", description: "电费自动缴费", income_expense: "支出", amount: "50.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-05 13:49:13", category: "餐饮美食", merchant: "友宝", description: "维他柠檬茶", income_expense: "支出", amount: "2.40", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-05 11:59:45", category: "餐饮美食", merchant: "金山武汉食堂", description: "小碗菜", income_expense: "支出", amount: "9.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-05 09:36:44", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "13.43", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-04 19:27:50", category: "日用百货", merchant: "朴朴超市", description: "商品订单", income_expense: "支出", amount: "52.77", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-04 17:06:22", category: "餐饮美食", merchant: "友宝", description: "香辣鸭翅根", income_expense: "支出", amount: "2.55", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-04 12:03:39", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-04 09:29:28", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "22.86", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-04 09:04:05", category: "餐饮美食", merchant: "巴比鲜包", description: "早餐", income_expense: "支出", amount: "8.00", payment_method: "花呗", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-03 22:30:01", category: "餐饮美食", merchant: "美团", description: "长沙臭豆腐", income_expense: "支出", amount: "20.88", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-03 17:04:37", category: "家居家装", merchant: "淘宝", description: "四件套", income_expense: "支出", amount: "156.35", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-03 13:44:03", category: "餐饮美食", merchant: "必胜客", description: "外卖订单", income_expense: "支出", amount: "55.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-03 10:16:31", category: "充值缴费", merchant: "湖北联通", description: "手机充值", income_expense: "支出", amount: "50.00", payment_method: "招商银行信用卡", status: "充值成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-03 00:17:12", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "17.45", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-02 21:29:15", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "20.65", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-02 15:39:08", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "12.61", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-02 13:30:02", category: "充值缴费", merchant: "武汉燃气集团", description: "燃气费", income_expense: "支出", amount: "300.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-02 12:06:04", category: "餐饮美食", merchant: "食寨香木甑饭", description: "外卖订单", income_expense: "支出", amount: "17.38", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-02 12:04:27", category: "运动健身", merchant: "携程", description: "武汉冰雪中心", income_expense: "支出", amount: "390.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-02 11:05:33", category: "充值缴费", merchant: "中国移动", description: "话费充值", income_expense: "支出", amount: "50.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-02 01:46:12", category: "充值缴费", merchant: "中国移动", description: "话费自动充值", income_expense: "支出", amount: "50.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-01 21:42:18", category: "文化休闲", merchant: "雷神", description: "超级会员", income_expense: "支出", amount: "88.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-01 20:44:27", category: "餐饮美食", merchant: "茶百道", description: "饮品", income_expense: "支出", amount: "6.85", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-01 14:29:46", category: "餐饮美食", merchant: "星巴克", description: "咖啡", income_expense: "支出", amount: "43.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-31 21:27:54", category: "餐饮美食", merchant: "星巴克", description: "咖啡", income_expense: "支出", amount: "43.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-31 14:00:33", category: "餐饮美食", merchant: "海底捞", description: "聚餐", income_expense: "支出", amount: "207.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-31 12:06:13", category: "餐饮美食", merchant: "coco", description: "饮品", income_expense: "支出", amount: "10.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-30 18:38:25", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "3.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-30 12:05:17", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-30 09:31:36", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "14.06", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-29 21:27:52", category: "餐饮美食", merchant: "茶百道", description: "饮品", income_expense: "支出", amount: "18.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-29 17:52:16", category: "家居家装", merchant: "淘宝", description: "家居用品", income_expense: "支出", amount: "131.80", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-29 12:05:20", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-28 17:12:07", category: "日用百货", merchant: "朴朴超市", description: "商品订单", income_expense: "支出", amount: "57.99", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-28 13:29:06", category: "餐饮美食", merchant: "美团", description: "老乡鸡", income_expense: "支出", amount: "38.60", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-28 11:50:34", category: "运动健身", merchant: "携程", description: "武汉冰雪中心", income_expense: "支出", amount: "340.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-27 18:34:40", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "5.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-27 12:05:32", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-27 09:50:53", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "15.62", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-26 18:34:52", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "3.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-26 12:04:30", category: "餐饮美食", merchant: "金山武汉食堂", description: "小碗菜", income_expense: "支出", amount: "10.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-26 09:09:26", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "22.53", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-25 21:48:08", category: "餐饮美食", merchant: "瑞幸咖啡", description: "咖啡", income_expense: "支出", amount: "6.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-25 18:44:14", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "5.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-25 12:06:00", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-25 09:54:55", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "19.62", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-24 19:45:03", category: "日用百货", merchant: "朴朴超市", description: "商品订单", income_expense: "支出", amount: "44.47", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-24 18:47:15", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "19.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-24 12:03:03", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-24 09:32:03", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "23.41", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-23 18:30:33", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "5.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-23 12:05:52", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-23 09:34:15", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "14.04", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-22 23:22:30", category: "餐饮美食", merchant: "可多", description: "便利店", income_expense: "支出", amount: "44.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-22 20:34:29", category: "餐饮美食", merchant: "美团", description: "外卖", income_expense: "支出", amount: "18.55", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-22 18:02:54", category: "运动健身", merchant: "携程", description: "武汉冰雪中心", income_expense: "支出", amount: "340.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-21 21:30:42", category: "餐饮美食", merchant: "美团", description: "外卖", income_expense: "支出", amount: "19.32", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-21 16:00:41", category: "餐饮美食", merchant: "美团", description: "外卖", income_expense: "支出", amount: "54.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-20 21:23:32", category: "餐饮美食", merchant: "星巴克", description: "咖啡", income_expense: "支出", amount: "34.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-20 18:29:55", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "3.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-20 12:05:11", category: "餐饮美食", merchant: "金山武汉食堂", description: "小碗菜", income_expense: "支出", amount: "10.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-20 09:20:06", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "16.67", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-19 20:24:43", category: "餐饮美食", merchant: "美团", description: "外卖", income_expense: "支出", amount: "26.40", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-19 18:30:04", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "3.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-19 12:03:27", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-19 09:24:21", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "16.47", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-18 18:31:46", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "3.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-18 12:06:47", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-18 09:20:38", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "20.15", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-17 18:33:54", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "8.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-17 12:04:46", category: "餐饮美食", merchant: "金山武汉食堂", description: "小碗菜", income_expense: "支出", amount: "10.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-17 09:43:18", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "18.72", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-16 18:29:23", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "8.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-16 12:05:02", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-16 09:48:27", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "16.30", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-15 20:47:21", category: "日用百货", merchant: "朴朴超市", description: "商品订单", income_expense: "支出", amount: "48.47", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-15 16:35:52", category: "运动健身", merchant: "携程", description: "武汉冰雪中心", income_expense: "支出", amount: "340.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-15 13:29:58", category: "餐饮美食", merchant: "美团", description: "外卖", income_expense: "支出", amount: "52.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-14 21:28:13", category: "餐饮美食", merchant: "美团", description: "外卖", income_expense: "支出", amount: "17.60", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-14 16:14:05", category: "餐饮美食", merchant: "美团", description: "外卖", income_expense: "支出", amount: "48.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-13 18:23:06", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "3.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-13 12:04:46", category: "餐饮美食", merchant: "金山武汉食堂", description: "小碗菜", income_expense: "支出", amount: "10.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-13 09:24:17", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "18.27", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-12 18:22:26", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "3.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-12 12:04:12", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-12 09:18:36", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "13.59", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-11 18:26:14", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "6.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-11 12:03:48", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-11 09:17:00", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "14.27", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-10 21:10:09", category: "餐饮美食", merchant: "瑞幸咖啡", description: "咖啡", income_expense: "支出", amount: "9.90", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-10 18:29:53", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "5.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-10 12:09:23", category: "餐饮美食", merchant: "金山武汉食堂", description: "烧腊", income_expense: "支出", amount: "23.80", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-10 09:23:16", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "13.70", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-09 18:26:48", category: "餐饮美食", merchant: "金山武汉食堂", description: "便利店", income_expense: "支出", amount: "3.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-09 12:08:06", category: "餐饮美食", merchant: "金山武汉食堂", description: "小碗菜", income_expense: "支出", amount: "10.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-09 09:32:16", category: "交通出行", merchant: "高德打车", description: "打车订单", income_expense: "支出", amount: "14.33", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-08 21:00:28", category: "日用百货", merchant: "朴朴超市", description: "商品订单", income_expense: "支出", amount: "57.50", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-08 16:19:40", category: "运动健身", merchant: "携程", description: "武汉冰雪中心", income_expense: "支出", amount: "340.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-08 14:15:07", category: "餐饮美食", merchant: "肯德基", description: "套餐", income_expense: "支出", amount: "44.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-07 21:05:23", category: "餐饮美食", merchant: "美团", description: "外卖", income_expense: "支出", amount: "25.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2025-12-07 15:06:26", category: "餐饮美食", merchant: "星巴克", description: "咖啡", income_expense: "支出", amount: "34.00", payment_method: "招商银行信用卡", status: "交易成功", remark: "", needs_review: "" },
|
||||||
|
|
||||||
// 生成指定日期的时间字符串
|
// ========== 微信数据 ==========
|
||||||
function formatDateTime(date: Date, hour: number, minute: number): string {
|
{ time: "2026-01-07 12:14:45", category: "餐饮美食", merchant: "瑞幸咖啡", description: "订单付款", income_expense: "支出", amount: "10.60", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
const y = date.getFullYear();
|
{ time: "2026-01-07 09:11:03", category: "交通出行", merchant: "广州骑安", description: "先乘车后付款", income_expense: "支出", amount: "1.50", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
{ time: "2026-01-07 09:06:40", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
{ time: "2026-01-07 09:01:42", category: "交通出行", merchant: "滴滴出行", description: "单车", income_expense: "支出", amount: "0.75", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
const h = String(hour).padStart(2, '0');
|
{ time: "2026-01-06 18:28:04", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
const min = String(minute).padStart(2, '0');
|
{ time: "2026-01-06 14:17:03", category: "医疗健康", merchant: "皮肤病诊所", description: "扫码付款", income_expense: "支出", amount: "40.00", payment_method: "微信零钱", status: "已转账", remark: "", needs_review: "" },
|
||||||
return `${y}-${m}-${d} ${h}:${min}:00`;
|
{ time: "2026-01-06 10:50:23", category: "其他收入", merchant: "微信转账", description: "转账", income_expense: "收入", amount: "80.00", payment_method: "微信零钱", status: "已到账", remark: "", needs_review: "" },
|
||||||
}
|
{ time: "2026-01-05 18:28:38", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
|
{ time: "2026-01-04 20:40:57", category: "交通出行", merchant: "京庐出行", description: "共享单车", income_expense: "支出", amount: "3.00", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
// 商家和消费场景配置
|
{ time: "2026-01-04 19:24:34", category: "日用百货", merchant: "盒马鲜生", description: "购物", income_expense: "支出", amount: "54.60", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
const merchants = {
|
{ time: "2026-01-04 18:38:16", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
餐饮美食: [
|
{ time: "2026-01-03 20:24:05", category: "餐饮美食", merchant: "金山武汉食堂", description: "食堂订餐", income_expense: "支出", amount: "9.00", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '金山武汉食堂', descriptions: ['午餐', '烧腊', '小碗菜'], amountRange: [12, 28] },
|
{ time: "2026-01-03 20:23:30", category: "餐饮美食", merchant: "金山武汉食堂", description: "食堂订餐", income_expense: "支出", amount: "8.00", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '瑞幸咖啡', descriptions: ['生椰拿铁', '美式', '拿铁'], amountRange: [9.9, 18] },
|
{ time: "2026-01-03 20:22:42", category: "餐饮美食", merchant: "金山武汉食堂", description: "食堂订餐", income_expense: "支出", amount: "9.00", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '星巴克', descriptions: ['美式咖啡', '拿铁', '星冰乐'], amountRange: [28, 45] },
|
{ time: "2026-01-03 20:21:58", category: "餐饮美食", merchant: "金山武汉食堂", description: "食堂订餐", income_expense: "支出", amount: "11.00", payment_method: "微信零钱", status: "支付成功", remark: "原金额12.00,退款1.00", needs_review: "" },
|
||||||
{ merchant: '麦当劳', descriptions: ['早餐套餐', '午餐套餐', '汉堡'], amountRange: [20, 45] },
|
{ time: "2026-01-03 20:21:26", category: "餐饮美食", merchant: "金山武汉食堂", description: "食堂订餐", income_expense: "支出", amount: "7.00", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '肯德基', descriptions: ['套餐', '炸鸡', '汉堡'], amountRange: [25, 50] },
|
{ time: "2026-01-02 22:32:23", category: "餐饮美食", merchant: "茶悦德", description: "饮品", income_expense: "支出", amount: "14.00", payment_method: "微信零钱", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '海底捞', descriptions: ['火锅', '聚餐'], amountRange: [150, 300] },
|
{ time: "2026-01-02 21:08:52", category: "其他收入", merchant: "微信转账", description: "转账", income_expense: "收入", amount: "100.00", payment_method: "微信零钱", status: "已到账", remark: "", needs_review: "" },
|
||||||
{ merchant: '美团外卖', descriptions: ['外卖订单', '午餐外卖', '晚餐外卖'], amountRange: [20, 50] },
|
{ time: "2026-01-02 15:43:56", category: "其他", merchant: "众雪Popsnowboard", description: "微信转账", income_expense: "支出", amount: "190.00", payment_method: "招商银行储蓄卡", status: "对方已收钱", remark: "", needs_review: "" },
|
||||||
{ merchant: '饿了么', descriptions: ['外卖订单', '午餐', '晚餐'], amountRange: [18, 45] },
|
{ time: "2026-01-02 14:00:55", category: "日用百货", merchant: "京东", description: "购物", income_expense: "支出", amount: "47.52", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '便利店', descriptions: ['零食', '饮料', '早餐'], amountRange: [8, 30] },
|
{ time: "2026-01-02 12:18:07", category: "日用百货", merchant: "沃尔玛", description: "购物", income_expense: "支出", amount: "12.29", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '喜茶', descriptions: ['多肉葡萄', '芝芝莓莓'], amountRange: [18, 32] },
|
{ time: "2025-12-31 20:21:14", category: "日用百货", merchant: "沃尔玛", description: "购物", income_expense: "支出", amount: "56.16", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
],
|
{ time: "2025-12-31 18:26:43", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
交通出行: [
|
{ time: "2025-12-30 19:04:35", category: "餐饮美食", merchant: "可多", description: "便利店", income_expense: "支出", amount: "14.50", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '高德打车', descriptions: ['打车订单', '快车'], amountRange: [12, 35] },
|
{ time: "2025-12-30 18:16:47", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '滴滴出行', descriptions: ['快车', '打车'], amountRange: [15, 40] },
|
{ time: "2025-12-29 18:51:56", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '武汉地铁', descriptions: ['地铁充值', '乘车'], amountRange: [50, 100] },
|
{ time: "2025-12-28 18:08:51", category: "餐饮美食", merchant: "星巴克", description: "咖啡", income_expense: "支出", amount: "33.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '哈啰单车', descriptions: ['骑行', '单车'], amountRange: [1.5, 5] },
|
{ time: "2025-12-28 16:02:06", category: "文化休闲", merchant: "CGV影城", description: "电影票", income_expense: "支出", amount: "85.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '中国石化', descriptions: ['加油', '油费'], amountRange: [200, 400] },
|
{ time: "2025-12-27 18:30:10", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
],
|
{ time: "2025-12-26 18:33:55", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
日用百货: [
|
{ time: "2025-12-25 18:30:56", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '朴朴超市', descriptions: ['日用品', '商品订单'], amountRange: [30, 80] },
|
{ time: "2025-12-25 12:37:44", category: "餐饮美食", merchant: "喜茶", description: "饮品", income_expense: "支出", amount: "23.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '盒马鲜生', descriptions: ['生鲜蔬果', '日用品'], amountRange: [50, 150] },
|
{ time: "2025-12-24 18:27:06", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '沃尔玛', descriptions: ['日用品采购', '超市购物'], amountRange: [80, 200] },
|
{ time: "2025-12-23 18:26:11", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '名创优品', descriptions: ['日用品', '小商品'], amountRange: [20, 60] },
|
{ time: "2025-12-22 20:08:27", category: "餐饮美食", merchant: "瑞幸咖啡", description: "咖啡", income_expense: "支出", amount: "9.90", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '屈臣氏', descriptions: ['洗护用品', '化妆品'], amountRange: [50, 150] },
|
{ time: "2025-12-21 19:25:12", category: "餐饮美食", merchant: "盒马鲜生", description: "购物", income_expense: "支出", amount: "41.30", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
],
|
{ time: "2025-12-20 18:26:51", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
充值缴费: [
|
{ time: "2025-12-19 18:31:02", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '武汉供电公司', descriptions: ['电费', '电费缴费'], amountRange: [50, 200] },
|
{ time: "2025-12-18 18:28:15", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '武汉燃气集团', descriptions: ['燃气费', '天然气'], amountRange: [100, 300] },
|
{ time: "2025-12-17 18:29:33", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '中国移动', descriptions: ['话费充值', '手机充值'], amountRange: [50, 100] },
|
{ time: "2025-12-16 18:30:27", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '中国联通', descriptions: ['话费充值', '手机充值'], amountRange: [50, 100] },
|
{ time: "2025-12-15 20:32:01", category: "餐饮美食", merchant: "必胜客", description: "套餐", income_expense: "支出", amount: "65.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '中国电信', descriptions: ['宽带续费', '话费'], amountRange: [100, 200] },
|
{ time: "2025-12-14 19:45:22", category: "餐饮美食", merchant: "星巴克", description: "咖啡", income_expense: "支出", amount: "37.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
],
|
{ time: "2025-12-13 18:27:08", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
服饰鞋包: [
|
{ time: "2025-12-12 18:28:41", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '优衣库', descriptions: ['衣服', '裤子', '外套'], amountRange: [100, 500] },
|
{ time: "2025-12-11 18:27:55", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: 'ZARA', descriptions: ['衣服', '外套'], amountRange: [200, 600] },
|
{ time: "2025-12-10 21:30:18", category: "餐饮美食", merchant: "麦当劳", description: "套餐", income_expense: "支出", amount: "35.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: 'Nike', descriptions: ['运动鞋', '运动服'], amountRange: [300, 800] },
|
{ time: "2025-12-10 18:29:02", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.00", payment_method: "招商银行信用卡", status: "支付成功", remark: "", needs_review: "" },
|
||||||
{ merchant: '淘宝', descriptions: ['服装', '鞋子'], amountRange: [80, 300] },
|
{ time: "2025-12-09 18:26:33", category: "交通出行", merchant: "金山软件", description: "通勤班车", income_expense: "支出", amount: "1.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: "" },
|
||||||
{ merchant: '京东', descriptions: ['数码配件', '电子产品'], amountRange: [50, 500] },
|
].sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
||||||
{ merchant: '天猫', descriptions: ['手机配件', '电子产品'], amountRange: [30, 200] },
|
|
||||||
{ merchant: '苹果官网', descriptions: ['配件', '保护壳'], amountRange: [100, 300] },
|
|
||||||
],
|
|
||||||
文化休闲: [
|
|
||||||
{ merchant: '腾讯视频', descriptions: ['VIP会员', '会员续费'], amountRange: [25, 30] },
|
|
||||||
{ merchant: '爱奇艺', descriptions: ['VIP会员', '会员'], amountRange: [25, 30] },
|
|
||||||
{ merchant: 'B站', descriptions: ['大会员', '会员'], amountRange: [25, 25] },
|
|
||||||
{ merchant: '万达影城', descriptions: ['电影票', '观影'], amountRange: [40, 100] },
|
|
||||||
{ merchant: '书店', descriptions: ['书籍', '购书'], amountRange: [30, 100] },
|
|
||||||
],
|
|
||||||
运动健身: [
|
|
||||||
{ merchant: '迪卡侬', descriptions: ['运动装备', '健身用品'], amountRange: [100, 300] },
|
|
||||||
{ merchant: '健身房', descriptions: ['月卡', '私教课'], amountRange: [200, 500] },
|
|
||||||
{ merchant: '携程', descriptions: ['滑雪', '运动场馆'], amountRange: [150, 400] },
|
|
||||||
],
|
|
||||||
医疗健康: [
|
|
||||||
{ merchant: '药店', descriptions: ['药品', '保健品'], amountRange: [30, 100] },
|
|
||||||
{ merchant: '医院', descriptions: ['挂号费', '门诊'], amountRange: [20, 100] },
|
|
||||||
],
|
|
||||||
家居家装: [
|
|
||||||
{ merchant: '淘宝', descriptions: ['家居用品', '四件套', '收纳'], amountRange: [50, 200] },
|
|
||||||
{ merchant: '宜家', descriptions: ['家具', '家居'], amountRange: [100, 500] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const paymentMethods = ['微信支付', '支付宝', '招商银行信用卡', '花呗', '工商银行储蓄卡'];
|
|
||||||
|
|
||||||
// 生成单条支出记录
|
|
||||||
function generateExpenseRecord(date: Date, category: string): BillRecord {
|
|
||||||
const categoryMerchants = merchants[category as keyof typeof merchants] || merchants['餐饮美食'];
|
|
||||||
const merchantInfo = categoryMerchants[Math.floor(Math.random() * categoryMerchants.length)];
|
|
||||||
const description = merchantInfo.descriptions[Math.floor(Math.random() * merchantInfo.descriptions.length)];
|
|
||||||
const amount = randomAmount(merchantInfo.amountRange[0], merchantInfo.amountRange[1]);
|
|
||||||
const paymentMethod = paymentMethods[Math.floor(Math.random() * paymentMethods.length)];
|
|
||||||
const hour = Math.floor(Math.random() * 14) + 8; // 8:00 - 22:00
|
|
||||||
const minute = Math.floor(Math.random() * 60);
|
|
||||||
|
|
||||||
return {
|
|
||||||
time: formatDateTime(date, hour, minute),
|
|
||||||
category,
|
|
||||||
merchant: merchantInfo.merchant,
|
|
||||||
description,
|
|
||||||
income_expense: '支出',
|
|
||||||
amount,
|
|
||||||
payment_method: paymentMethod,
|
|
||||||
status: '交易成功',
|
|
||||||
remark: '',
|
|
||||||
needs_review: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成收入记录
|
|
||||||
function generateIncomeRecord(date: Date): BillRecord {
|
|
||||||
const incomeTypes = [
|
|
||||||
{ category: '退款', merchant: '淘宝', description: '商品退款', amountRange: [30, 200] },
|
|
||||||
{ category: '退款', merchant: '京东', description: '退货退款', amountRange: [50, 300] },
|
|
||||||
{ category: '其他收入', merchant: '微信红包', description: '红包', amountRange: [5, 100] },
|
|
||||||
{ category: '其他收入', merchant: '支付宝', description: '余额宝收益', amountRange: [1, 20] },
|
|
||||||
{ category: '其他收入', merchant: '微信转账', description: '朋友转账', amountRange: [100, 500] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const incomeInfo = incomeTypes[Math.floor(Math.random() * incomeTypes.length)];
|
|
||||||
const amount = randomAmount(incomeInfo.amountRange[0], incomeInfo.amountRange[1]);
|
|
||||||
const hour = Math.floor(Math.random() * 14) + 8;
|
|
||||||
const minute = Math.floor(Math.random() * 60);
|
|
||||||
|
|
||||||
return {
|
|
||||||
time: formatDateTime(date, hour, minute),
|
|
||||||
category: incomeInfo.category,
|
|
||||||
merchant: incomeInfo.merchant,
|
|
||||||
description: incomeInfo.description,
|
|
||||||
income_expense: '收入',
|
|
||||||
amount,
|
|
||||||
payment_method: incomeInfo.category === '退款' ? '原路退回' : '微信零钱',
|
|
||||||
status: incomeInfo.category === '退款' ? '退款成功' : '已到账',
|
|
||||||
remark: '',
|
|
||||||
needs_review: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成一天的记录
|
|
||||||
function generateDayRecords(date: Date): BillRecord[] {
|
|
||||||
const records: BillRecord[] = [];
|
|
||||||
const dayOfWeek = date.getDay();
|
|
||||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
|
||||||
|
|
||||||
// 每天固定的支出
|
|
||||||
const categories = Object.keys(merchants);
|
|
||||||
|
|
||||||
// 工作日:早餐、午餐、晚餐、交通
|
|
||||||
// 周末:可能有更多消费
|
|
||||||
const baseExpenses = isWeekend ?
|
|
||||||
Math.floor(Math.random() * 4) + 4 : // 周末 4-7 笔
|
|
||||||
Math.floor(Math.random() * 3) + 3; // 工作日 3-5 笔
|
|
||||||
|
|
||||||
// 必有餐饮
|
|
||||||
records.push(generateExpenseRecord(date, '餐饮美食'));
|
|
||||||
records.push(generateExpenseRecord(date, '餐饮美食'));
|
|
||||||
|
|
||||||
// 可能有交通
|
|
||||||
if (Math.random() > 0.3) {
|
|
||||||
records.push(generateExpenseRecord(date, '交通出行'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 随机其他消费
|
|
||||||
for (let i = 0; i < baseExpenses - 2; i++) {
|
|
||||||
const randomCategory = categories[Math.floor(Math.random() * categories.length)];
|
|
||||||
records.push(generateExpenseRecord(date, randomCategory));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10% 概率有收入
|
|
||||||
if (Math.random() < 0.1) {
|
|
||||||
records.push(generateIncomeRecord(date));
|
|
||||||
}
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成指定日期范围的所有记录
|
|
||||||
function generateRecords(startDate: Date, endDate: Date): BillRecord[] {
|
|
||||||
const records: BillRecord[] = [];
|
|
||||||
const currentDate = new Date(startDate);
|
|
||||||
|
|
||||||
while (currentDate <= endDate) {
|
|
||||||
records.push(...generateDayRecords(new Date(currentDate)));
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按时间排序(最新的在前)
|
|
||||||
return records.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成从2025年10月1日到2026年1月8日的数据
|
|
||||||
const startDate = new Date(2025, 9, 1); // 2025-10-01
|
|
||||||
const endDate = new Date(2026, 0, 8); // 2026-01-08
|
|
||||||
|
|
||||||
/** 演示数据(支付宝 + 微信支付混合数据,覆盖约100天) */
|
|
||||||
export const demoRecords: BillRecord[] = generateRecords(startDate, endDate);
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Avatar from '$lib/components/ui/avatar';
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
|
||||||
|
// Icons
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import ClipboardCheck from '@lucide/svelte/icons/clipboard-check';
|
import ClipboardCheck from '@lucide/svelte/icons/clipboard-check';
|
||||||
import FileText from '@lucide/svelte/icons/file-text';
|
import FileText from '@lucide/svelte/icons/file-text';
|
||||||
@@ -13,30 +15,65 @@
|
|||||||
import Settings from '@lucide/svelte/icons/settings';
|
import Settings from '@lucide/svelte/icons/settings';
|
||||||
import HelpCircle from '@lucide/svelte/icons/help-circle';
|
import HelpCircle from '@lucide/svelte/icons/help-circle';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import Moon from '@lucide/svelte/icons/moon';
|
|
||||||
import Sun from '@lucide/svelte/icons/sun';
|
|
||||||
import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
|
import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
|
||||||
|
import Wallet from '@lucide/svelte/icons/wallet';
|
||||||
|
import LogOut from '@lucide/svelte/icons/log-out';
|
||||||
|
import User from '@lucide/svelte/icons/user';
|
||||||
|
import Bell from '@lucide/svelte/icons/bell';
|
||||||
|
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
import {
|
||||||
|
type ThemeMode,
|
||||||
|
themeConfig,
|
||||||
|
getNextTheme,
|
||||||
|
applyThemeToDocument,
|
||||||
|
loadThemeFromStorage,
|
||||||
|
saveThemeToStorage
|
||||||
|
} from '$lib/config/theme';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let darkMode = $state(false);
|
let themeMode = $state<ThemeMode>('system');
|
||||||
|
|
||||||
function toggleDarkMode() {
|
onMount(() => {
|
||||||
darkMode = !darkMode;
|
themeMode = loadThemeFromStorage();
|
||||||
if (darkMode) {
|
applyThemeToDocument(themeMode);
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
// 监听系统主题变化
|
||||||
document.documentElement.classList.remove('dark');
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
}
|
const handleChange = () => applyThemeToDocument(themeMode);
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
function cycleTheme() {
|
||||||
|
themeMode = getNextTheme(themeMode);
|
||||||
|
saveThemeToStorage(themeMode);
|
||||||
|
applyThemeToDocument(themeMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainNavItems = [
|
// 主导航
|
||||||
{ href: '/', label: '上传', icon: Upload },
|
const navMain = [
|
||||||
{ href: '/review', label: '复核', icon: ClipboardCheck },
|
{ href: '/', label: '上传账单', icon: Upload },
|
||||||
{ href: '/bills', label: '账单', icon: FileText },
|
{ href: '/review', label: '智能复核', icon: ClipboardCheck },
|
||||||
{ href: '/analysis', label: '分析', icon: BarChart3 },
|
{ href: '/bills', label: '账单管理', icon: FileText },
|
||||||
|
{ href: '/analysis', label: '数据分析', icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 次级导航(底部)
|
||||||
|
const navSecondary = [
|
||||||
|
{ href: '/settings', label: '设置', icon: Settings },
|
||||||
|
{ href: '/help', label: '帮助', icon: HelpCircle },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 用户数据
|
||||||
|
const user = {
|
||||||
|
name: '用户',
|
||||||
|
email: 'user@example.com',
|
||||||
|
avatar: ''
|
||||||
|
};
|
||||||
|
|
||||||
function isActive(href: string, pathname: string): boolean {
|
function isActive(href: string, pathname: string): boolean {
|
||||||
if (href === '/') return pathname === '/';
|
if (href === '/') return pathname === '/';
|
||||||
return pathname.startsWith(href);
|
return pathname.startsWith(href);
|
||||||
@@ -44,57 +81,33 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sidebar.Provider>
|
<Sidebar.Provider>
|
||||||
<Sidebar.Root>
|
<Sidebar.Root collapsible="offcanvas">
|
||||||
|
<!-- Header: Logo + App Name -->
|
||||||
<Sidebar.Header>
|
<Sidebar.Header>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<DropdownMenu.Root>
|
<Sidebar.MenuButton class="!p-1.5">
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Sidebar.MenuButton
|
<a href="/" {...props} class="flex items-center gap-2">
|
||||||
{...props}
|
<div class="flex size-8 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-amber-500 text-white">
|
||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
<Wallet class="size-5" />
|
||||||
>
|
|
||||||
<div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
|
||||||
<span class="text-lg">💰</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<span class="text-base font-semibold">BillAI</span>
|
||||||
<span class="truncate font-semibold">BillAI</span>
|
</a>
|
||||||
<span class="truncate text-xs text-muted-foreground">智能账单分析</span>
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDown class="ml-auto" />
|
|
||||||
</Sidebar.MenuButton>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DropdownMenu.Trigger>
|
</Sidebar.MenuButton>
|
||||||
<DropdownMenu.Content
|
|
||||||
class="w-[--bits-dropdown-menu-anchor-width] min-w-56 rounded-lg"
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
sideOffset={4}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label class="text-xs text-muted-foreground">
|
|
||||||
版本信息
|
|
||||||
</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item>
|
|
||||||
<span class="mr-2">📦</span>
|
|
||||||
v0.1.0 Beta
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
</Sidebar.Menu>
|
</Sidebar.Menu>
|
||||||
</Sidebar.Header>
|
</Sidebar.Header>
|
||||||
|
|
||||||
<Sidebar.Content>
|
<Sidebar.Content>
|
||||||
|
<!-- 主导航 -->
|
||||||
<Sidebar.Group>
|
<Sidebar.Group>
|
||||||
<Sidebar.GroupLabel>主要功能</Sidebar.GroupLabel>
|
|
||||||
<Sidebar.GroupContent>
|
<Sidebar.GroupContent>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
{#each mainNavItems as item}
|
{#each navMain as item}
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton
|
<Sidebar.MenuButton isActive={isActive(item.href, $page.url.pathname)}>
|
||||||
isActive={isActive(item.href, $page.url.pathname)}
|
|
||||||
>
|
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href={item.href} {...props}>
|
<a href={item.href} {...props}>
|
||||||
<item.icon class="size-4" />
|
<item.icon class="size-4" />
|
||||||
@@ -108,50 +121,41 @@
|
|||||||
</Sidebar.GroupContent>
|
</Sidebar.GroupContent>
|
||||||
</Sidebar.Group>
|
</Sidebar.Group>
|
||||||
|
|
||||||
<Sidebar.Group>
|
<!-- 次级导航 (底部) -->
|
||||||
<Sidebar.GroupLabel>系统</Sidebar.GroupLabel>
|
<Sidebar.Group class="mt-auto">
|
||||||
<Sidebar.GroupContent>
|
<Sidebar.GroupContent>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
|
<!-- 主题切换 -->
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton>
|
<Sidebar.MenuButton>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<button {...props} onclick={toggleDarkMode}>
|
{@const theme = themeConfig[themeMode]}
|
||||||
{#if darkMode}
|
<button {...props} onclick={cycleTheme}>
|
||||||
<Sun class="size-4" />
|
<theme.icon class="size-4" />
|
||||||
<span>浅色模式</span>
|
<span>{theme.label}</span>
|
||||||
{:else}
|
|
||||||
<Moon class="size-4" />
|
|
||||||
<span>深色模式</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
|
|
||||||
|
{#each navSecondary as item}
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton>
|
<Sidebar.MenuButton>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a href="/settings" {...props}>
|
<a href={item.href} {...props}>
|
||||||
<Settings class="size-4" />
|
<item.icon class="size-4" />
|
||||||
<span>设置</span>
|
<span>{item.label}</span>
|
||||||
</a>
|
|
||||||
{/snippet}
|
|
||||||
</Sidebar.MenuButton>
|
|
||||||
</Sidebar.MenuItem>
|
|
||||||
<Sidebar.MenuItem>
|
|
||||||
<Sidebar.MenuButton>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<a href="/help" {...props}>
|
|
||||||
<HelpCircle class="size-4" />
|
|
||||||
<span>帮助</span>
|
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
|
{/each}
|
||||||
</Sidebar.Menu>
|
</Sidebar.Menu>
|
||||||
</Sidebar.GroupContent>
|
</Sidebar.GroupContent>
|
||||||
</Sidebar.Group>
|
</Sidebar.Group>
|
||||||
</Sidebar.Content>
|
</Sidebar.Content>
|
||||||
|
|
||||||
|
<!-- Footer: 用户信息 -->
|
||||||
<Sidebar.Footer>
|
<Sidebar.Footer>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
@@ -163,13 +167,13 @@
|
|||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<Avatar.Root class="h-8 w-8 rounded-lg">
|
<Avatar.Root class="h-8 w-8 rounded-lg">
|
||||||
<Avatar.Fallback class="rounded-lg bg-gradient-to-br from-primary to-chart-1 text-primary-foreground">
|
<Avatar.Fallback class="rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 text-white font-medium">
|
||||||
U
|
{user.name.charAt(0).toUpperCase()}
|
||||||
</Avatar.Fallback>
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">用户</span>
|
<span class="truncate font-semibold">{user.name}</span>
|
||||||
<span class="truncate text-xs text-muted-foreground">user@example.com</span>
|
<span class="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown class="ml-auto size-4" />
|
<ChevronsUpDown class="ml-auto size-4" />
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
@@ -184,23 +188,43 @@
|
|||||||
<DropdownMenu.Label class="p-0 font-normal">
|
<DropdownMenu.Label class="p-0 font-normal">
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar.Root class="h-8 w-8 rounded-lg">
|
<Avatar.Root class="h-8 w-8 rounded-lg">
|
||||||
<Avatar.Fallback class="rounded-lg bg-gradient-to-br from-primary to-chart-1 text-primary-foreground">
|
<Avatar.Fallback class="rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 text-white font-medium">
|
||||||
U
|
{user.name.charAt(0).toUpperCase()}
|
||||||
</Avatar.Fallback>
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">用户</span>
|
<span class="truncate font-semibold">{user.name}</span>
|
||||||
<span class="truncate text-xs text-muted-foreground">user@example.com</span>
|
<span class="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.Item>
|
<DropdownMenu.Item>
|
||||||
<Settings class="mr-2 size-4" />
|
<Sparkles class="mr-2 size-4" />
|
||||||
账户设置
|
升级到 Pro
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<User class="mr-2 size-4" />
|
||||||
|
账户
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<Settings class="mr-2 size-4" />
|
||||||
|
设置
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<Bell class="mr-2 size-4" />
|
||||||
|
通知
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<LogOut class="mr-2 size-4" />
|
||||||
|
退出登录
|
||||||
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
@@ -210,7 +234,7 @@
|
|||||||
</Sidebar.Root>
|
</Sidebar.Root>
|
||||||
|
|
||||||
<Sidebar.Inset>
|
<Sidebar.Inset>
|
||||||
<header class="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
<header class="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
||||||
<Sidebar.Trigger class="-ml-1" />
|
<Sidebar.Trigger class="-ml-1" />
|
||||||
<Separator orientation="vertical" class="mr-2 h-4" />
|
<Separator orientation="vertical" class="mr-2 h-4" />
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user