fix: 修复微信账单金额解析问题(半角¥符号支持)
- 修复 parse_amount 函数同时支持全角¥和半角¥ - 新增 MonthRangePicker 日期选择组件 - 新增 /api/monthly-stats 接口获取月度统计 - 分析页面月度趋势使用全量数据 - 新增健康检查路由
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -20,3 +20,5 @@ server/billai-server
|
|||||||
server/uploads/
|
server/uploads/
|
||||||
server/outputs/
|
server/outputs/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
mongodata/
|
||||||
@@ -25,9 +25,10 @@ def parse_date(date_str: str) -> date:
|
|||||||
|
|
||||||
|
|
||||||
def parse_amount(amount_str: str) -> Decimal:
|
def parse_amount(amount_str: str) -> Decimal:
|
||||||
"""解析金额字符串为Decimal(去掉¥符号)"""
|
"""解析金额字符串为Decimal(去掉¥/¥符号)"""
|
||||||
try:
|
try:
|
||||||
clean = amount_str.replace("¥", "").replace(" ", "").strip()
|
# 同时处理全角¥和半角¥
|
||||||
|
clean = amount_str.replace("¥", "").replace("¥", "").replace(" ", "").strip()
|
||||||
return Decimal(clean)
|
return Decimal(clean)
|
||||||
except:
|
except:
|
||||||
return Decimal("0")
|
return Decimal("0")
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
- 煲仔饭
|
- 煲仔饭
|
||||||
- 蛙来哒 # 牛蛙餐厅
|
- 蛙来哒 # 牛蛙餐厅
|
||||||
- 粒上皇 # 炒货零食店
|
- 粒上皇 # 炒货零食店
|
||||||
|
- 盒马
|
||||||
|
|
||||||
# 转账红包
|
# 转账红包
|
||||||
转账红包:
|
转账红包:
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ from cleaners.base import compute_date_range_from_values
|
|||||||
from cleaners import AlipayCleaner, WechatCleaner
|
from cleaners import AlipayCleaner, WechatCleaner
|
||||||
from category import infer_category, get_all_categories, get_all_income_categories
|
from category import infer_category, get_all_categories, get_all_income_categories
|
||||||
|
|
||||||
|
# 应用版本
|
||||||
|
APP_VERSION = "0.0.1"
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Pydantic 模型
|
# Pydantic 模型
|
||||||
@@ -183,7 +186,7 @@ app = FastAPI(
|
|||||||
@app.get("/health", response_model=HealthResponse)
|
@app.get("/health", response_model=HealthResponse)
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""健康检查"""
|
"""健康检查"""
|
||||||
return HealthResponse(status="ok", version="1.0.0")
|
return HealthResponse(status="ok", version=APP_VERSION)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/clean", response_model=CleanResponse)
|
@app.post("/clean", response_model=CleanResponse)
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ services:
|
|||||||
MONGO_INITDB_ROOT_USERNAME: admin
|
MONGO_INITDB_ROOT_USERNAME: admin
|
||||||
MONGO_INITDB_ROOT_PASSWORD: password
|
MONGO_INITDB_ROOT_PASSWORD: password
|
||||||
volumes:
|
volumes:
|
||||||
- ./mongo/data/db:/data/db
|
- ./mongodata/db:/data/db
|
||||||
- ./mongo/data/configdb:/data/configdb
|
- ./mongodata/configdb:/data/configdb
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# BillAI 服务器配置文件
|
# BillAI 服务器配置文件
|
||||||
|
|
||||||
|
# 应用版本
|
||||||
|
version: "0.0.1"
|
||||||
|
|
||||||
# 服务配置
|
# 服务配置
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
// Config 服务配置
|
// Config 服务配置
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Version string // 应用版本
|
||||||
Port string // 服务端口
|
Port string // 服务端口
|
||||||
ProjectRoot string // 项目根目录
|
ProjectRoot string // 项目根目录
|
||||||
PythonPath string // Python 解释器路径
|
PythonPath string // Python 解释器路径
|
||||||
@@ -31,6 +32,7 @@ type Config struct {
|
|||||||
|
|
||||||
// configFile YAML 配置文件结构
|
// configFile YAML 配置文件结构
|
||||||
type configFile struct {
|
type configFile struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
Server struct {
|
Server struct {
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
} `yaml:"server"`
|
} `yaml:"server"`
|
||||||
@@ -117,6 +119,7 @@ func Load() {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// 设置默认值
|
// 设置默认值
|
||||||
|
Global.Version = "0.0.1"
|
||||||
Global.Port = getEnvOrDefault("PORT", "8080")
|
Global.Port = getEnvOrDefault("PORT", "8080")
|
||||||
Global.ProjectRoot = getDefaultProjectRoot()
|
Global.ProjectRoot = getDefaultProjectRoot()
|
||||||
Global.PythonPath = getDefaultPythonPath()
|
Global.PythonPath = getDefaultPythonPath()
|
||||||
@@ -145,6 +148,9 @@ func Load() {
|
|||||||
// 加载配置文件
|
// 加载配置文件
|
||||||
if cfg := loadConfigFile(configPath); cfg != nil {
|
if cfg := loadConfigFile(configPath); cfg != nil {
|
||||||
fmt.Printf("📄 加载配置文件: %s\n", configPath)
|
fmt.Printf("📄 加载配置文件: %s\n", configPath)
|
||||||
|
if cfg.Version != "" {
|
||||||
|
Global.Version = cfg.Version
|
||||||
|
}
|
||||||
if cfg.Server.Port > 0 {
|
if cfg.Server.Port > 0 {
|
||||||
Global.Port = fmt.Sprintf("%d", cfg.Server.Port)
|
Global.Port = fmt.Sprintf("%d", cfg.Server.Port)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,3 +165,36 @@ func parsePageParam(s string, defaultVal int) int {
|
|||||||
}
|
}
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MonthlyStatsResponse 月度统计响应
|
||||||
|
type MonthlyStatsResponse struct {
|
||||||
|
Result bool `json:"result"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Data []model.MonthlyStat `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonthlyStats 获取月度统计数据(全部数据,不受筛选条件影响)
|
||||||
|
func MonthlyStats(c *gin.Context) {
|
||||||
|
repo := repository.GetRepository()
|
||||||
|
if repo == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, MonthlyStatsResponse{
|
||||||
|
Result: false,
|
||||||
|
Message: "数据库未连接",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := repo.GetMonthlyStats()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, MonthlyStatsResponse{
|
||||||
|
Result: false,
|
||||||
|
Message: "查询失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, MonthlyStatsResponse{
|
||||||
|
Result: true,
|
||||||
|
Data: stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func main() {
|
|||||||
// 注册路由
|
// 注册路由
|
||||||
router.Setup(r, router.Config{
|
router.Setup(r, router.Config{
|
||||||
OutputDir: outputDirAbs,
|
OutputDir: outputDirAbs,
|
||||||
PythonPath: pythonPathAbs,
|
Version: config.Global.Version,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听系统信号
|
// 监听系统信号
|
||||||
|
|||||||
@@ -38,3 +38,10 @@ type CleanedBill struct {
|
|||||||
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
|
||||||
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
|
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MonthlyStat 月度统计数据
|
||||||
|
type MonthlyStat struct {
|
||||||
|
Month string `bson:"month" json:"month"` // 月份 YYYY-MM
|
||||||
|
Expense float64 `bson:"expense" json:"expense"` // 月支出总额
|
||||||
|
Income float64 `bson:"income" json:"income"` // 月收入总额
|
||||||
|
}
|
||||||
|
|||||||
@@ -303,6 +303,86 @@ func (r *Repository) GetBillsNeedReview() ([]model.CleanedBill, error) {
|
|||||||
return r.GetCleanedBills(filter)
|
return r.GetCleanedBills(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMonthlyStats 获取月度统计(全部数据,不受筛选条件影响)
|
||||||
|
func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 使用聚合管道按月份分组统计
|
||||||
|
// 先按月份和收支类型分组,再汇总
|
||||||
|
pipeline := mongo.Pipeline{
|
||||||
|
// 添加月份字段
|
||||||
|
{{Key: "$addFields", Value: bson.D{
|
||||||
|
{Key: "month", Value: bson.D{
|
||||||
|
{Key: "$dateToString", Value: bson.D{
|
||||||
|
{Key: "format", Value: "%Y-%m"},
|
||||||
|
{Key: "date", Value: "$time"},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}}},
|
||||||
|
// 按月份和收支类型分组
|
||||||
|
{{Key: "$group", Value: bson.D{
|
||||||
|
{Key: "_id", Value: bson.D{
|
||||||
|
{Key: "month", Value: "$month"},
|
||||||
|
{Key: "income_expense", Value: "$income_expense"},
|
||||||
|
}},
|
||||||
|
{Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}},
|
||||||
|
}}},
|
||||||
|
// 按月份重新分组,汇总收入和支出
|
||||||
|
{{Key: "$group", Value: bson.D{
|
||||||
|
{Key: "_id", Value: "$_id.month"},
|
||||||
|
{Key: "expense", Value: bson.D{
|
||||||
|
{Key: "$sum", Value: bson.D{
|
||||||
|
{Key: "$cond", Value: bson.A{
|
||||||
|
bson.D{{Key: "$eq", Value: bson.A{"$_id.income_expense", "支出"}}},
|
||||||
|
"$total",
|
||||||
|
0,
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
{Key: "income", Value: bson.D{
|
||||||
|
{Key: "$sum", Value: bson.D{
|
||||||
|
{Key: "$cond", Value: bson.A{
|
||||||
|
bson.D{{Key: "$eq", Value: bson.A{"$_id.income_expense", "收入"}}},
|
||||||
|
"$total",
|
||||||
|
0,
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}}},
|
||||||
|
// 按月份排序
|
||||||
|
{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor, err := r.cleanedCollection.Aggregate(ctx, pipeline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("月度统计聚合失败: %w", err)
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
// 解析结果
|
||||||
|
var results []struct {
|
||||||
|
Month string `bson:"_id"`
|
||||||
|
Expense float64 `bson:"expense"`
|
||||||
|
Income float64 `bson:"income"`
|
||||||
|
}
|
||||||
|
if err := cursor.All(ctx, &results); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析月度统计结果失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 MonthlyStat
|
||||||
|
stats := make([]model.MonthlyStat, len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
stats[i] = model.MonthlyStat{
|
||||||
|
Month: r.Month,
|
||||||
|
Expense: r.Expense,
|
||||||
|
Income: r.Income,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
||||||
func (r *Repository) GetClient() *mongo.Client {
|
func (r *Repository) GetClient() *mongo.Client {
|
||||||
return r.client
|
return r.client
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ type BillRepository interface {
|
|||||||
// 返回: 总支出、总收入、错误
|
// 返回: 总支出、总收入、错误
|
||||||
GetBillsAggregate(filter map[string]interface{}) (totalExpense float64, totalIncome float64, err error)
|
GetBillsAggregate(filter map[string]interface{}) (totalExpense float64, totalIncome float64, err error)
|
||||||
|
|
||||||
|
// GetMonthlyStats 获取月度统计(全部数据,不受筛选条件影响)
|
||||||
|
// 返回: 月度统计列表、错误
|
||||||
|
GetMonthlyStats() ([]model.MonthlyStat, error)
|
||||||
|
|
||||||
// GetBillsNeedReview 获取需要复核的账单
|
// GetBillsNeedReview 获取需要复核的账单
|
||||||
GetBillsNeedReview() ([]model.CleanedBill, error)
|
GetBillsNeedReview() ([]model.CleanedBill, error)
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import (
|
|||||||
// Config 路由配置参数
|
// Config 路由配置参数
|
||||||
type Config struct {
|
type Config struct {
|
||||||
OutputDir string // 输出目录(用于静态文件服务)
|
OutputDir string // 输出目录(用于静态文件服务)
|
||||||
PythonPath string // Python 路径(用于健康检查显示)
|
Version string // 应用版本
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup 设置所有路由
|
// Setup 设置所有路由
|
||||||
func Setup(r *gin.Engine, cfg Config) {
|
func Setup(r *gin.Engine, cfg Config) {
|
||||||
// 健康检查
|
// 健康检查
|
||||||
r.GET("/health", healthCheck(cfg.PythonPath))
|
r.GET("/health", healthCheck(cfg.Version))
|
||||||
|
|
||||||
// API 路由组
|
// API 路由组
|
||||||
setupAPIRoutes(r)
|
setupAPIRoutes(r)
|
||||||
@@ -28,11 +28,11 @@ func Setup(r *gin.Engine, cfg Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// healthCheck 健康检查处理器
|
// healthCheck 健康检查处理器
|
||||||
func healthCheck(pythonPath string) gin.HandlerFunc {
|
func healthCheck(version string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"python_path": pythonPath,
|
"version": version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,5 +49,8 @@ func setupAPIRoutes(r *gin.Engine) {
|
|||||||
|
|
||||||
// 账单查询
|
// 账单查询
|
||||||
api.GET("/bills", handler.ListBills)
|
api.GET("/bills", handler.ListBills)
|
||||||
|
|
||||||
|
// 月度统计(全部数据)
|
||||||
|
api.GET("/monthly-stats", handler.MonthlyStats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,19 @@ export interface ReviewResponse {
|
|||||||
data?: ReviewData;
|
data?: ReviewData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 月度统计数据
|
||||||
|
export interface MonthlyStat {
|
||||||
|
month: string; // YYYY-MM
|
||||||
|
expense: number;
|
||||||
|
income: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyStatsResponse {
|
||||||
|
result: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: MonthlyStat[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface BillRecord {
|
export interface BillRecord {
|
||||||
time: string;
|
time: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -109,6 +122,17 @@ export async function getReviewRecords(fileName: string): Promise<ReviewResponse
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取月度统计(全部数据,不受筛选条件影响)
|
||||||
|
export async function fetchMonthlyStats(): Promise<MonthlyStatsResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/api/monthly-stats`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// 下载文件 URL
|
// 下载文件 URL
|
||||||
export function getDownloadUrl(fileUrl: string): string {
|
export function getDownloadUrl(fileUrl: string): string {
|
||||||
return `${API_BASE}${fileUrl}`;
|
return `${API_BASE}${fileUrl}`;
|
||||||
|
|||||||
3
web/src/lib/components/ui/month-range-picker/index.ts
Normal file
3
web/src/lib/components/ui/month-range-picker/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import MonthRangePicker from './month-range-picker.svelte';
|
||||||
|
|
||||||
|
export { MonthRangePicker };
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CalendarIcon from "@lucide/svelte/icons/calendar";
|
||||||
|
import * as Popover from "$lib/components/ui/popover";
|
||||||
|
import * as Select from "$lib/components/ui/select";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
startYear?: number;
|
||||||
|
startMonth?: number;
|
||||||
|
startDay?: number;
|
||||||
|
endYear?: number;
|
||||||
|
endMonth?: number;
|
||||||
|
endDay?: number;
|
||||||
|
onchange?: (startYear: number, startMonth: number, startDay: number, endYear: number, endMonth: number, endDay: number) => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
startYear = $bindable(),
|
||||||
|
startMonth = $bindable(),
|
||||||
|
startDay = $bindable(),
|
||||||
|
endYear = $bindable(),
|
||||||
|
endMonth = $bindable(),
|
||||||
|
endDay = $bindable(),
|
||||||
|
onchange,
|
||||||
|
class: className
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// 生成年份列表 (当前年份前后5年)
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const years = Array.from({ length: 11 }, (_, i) => currentYear - 5 + i);
|
||||||
|
|
||||||
|
// 月份列表
|
||||||
|
const months = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
// 获取某月的天数
|
||||||
|
function getDaysInMonth(year: number | undefined, month: number | undefined): number[] {
|
||||||
|
if (!year || !month) return Array.from({ length: 31 }, (_, i) => i + 1);
|
||||||
|
const days = new Date(year, month, 0).getDate();
|
||||||
|
return Array.from({ length: days }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部状态
|
||||||
|
let internalStartYear = $state(startYear);
|
||||||
|
let internalStartMonth = $state(startMonth);
|
||||||
|
let internalStartDay = $state(startDay);
|
||||||
|
let internalEndYear = $state(endYear);
|
||||||
|
let internalEndMonth = $state(endMonth);
|
||||||
|
let internalEndDay = $state(endDay);
|
||||||
|
|
||||||
|
// 动态计算每月天数
|
||||||
|
let startDays = $derived(getDaysInMonth(internalStartYear, internalStartMonth));
|
||||||
|
let endDays = $derived(getDaysInMonth(internalEndYear, internalEndMonth));
|
||||||
|
|
||||||
|
// 同步外部值变化到内部
|
||||||
|
$effect(() => {
|
||||||
|
if (startYear !== undefined) internalStartYear = startYear;
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (startMonth !== undefined) internalStartMonth = startMonth;
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (startDay !== undefined) internalStartDay = startDay;
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (endYear !== undefined) internalEndYear = endYear;
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (endMonth !== undefined) internalEndMonth = endMonth;
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (endDay !== undefined) internalEndDay = endDay;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化显示
|
||||||
|
function formatDate(year: number | undefined, month: number | undefined, day: number | undefined): string {
|
||||||
|
if (!year || !month || !day) return '';
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayText = $derived(() => {
|
||||||
|
const start = formatDate(internalStartYear, internalStartMonth, internalStartDay);
|
||||||
|
const end = formatDate(internalEndYear, internalEndMonth, internalEndDay);
|
||||||
|
if (start && end) {
|
||||||
|
return `${start} ~ ${end}`;
|
||||||
|
}
|
||||||
|
if (start) {
|
||||||
|
return `${start} ~ `;
|
||||||
|
}
|
||||||
|
return "选择日期范围";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当选择改变时触发
|
||||||
|
function handleStartYearChange(value: string | undefined) {
|
||||||
|
if (value) {
|
||||||
|
internalStartYear = parseInt(value);
|
||||||
|
startYear = internalStartYear;
|
||||||
|
// 检查日期是否超出范围
|
||||||
|
validateStartDay();
|
||||||
|
notifyChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStartMonthChange(value: string | undefined) {
|
||||||
|
if (value) {
|
||||||
|
internalStartMonth = parseInt(value);
|
||||||
|
startMonth = internalStartMonth;
|
||||||
|
// 检查日期是否超出范围
|
||||||
|
validateStartDay();
|
||||||
|
notifyChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStartDayChange(value: string | undefined) {
|
||||||
|
if (value) {
|
||||||
|
internalStartDay = parseInt(value);
|
||||||
|
startDay = internalStartDay;
|
||||||
|
notifyChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEndYearChange(value: string | undefined) {
|
||||||
|
if (value) {
|
||||||
|
internalEndYear = parseInt(value);
|
||||||
|
endYear = internalEndYear;
|
||||||
|
// 检查日期是否超出范围
|
||||||
|
validateEndDay();
|
||||||
|
notifyChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEndMonthChange(value: string | undefined) {
|
||||||
|
if (value) {
|
||||||
|
internalEndMonth = parseInt(value);
|
||||||
|
endMonth = internalEndMonth;
|
||||||
|
// 检查日期是否超出范围
|
||||||
|
validateEndDay();
|
||||||
|
notifyChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEndDayChange(value: string | undefined) {
|
||||||
|
if (value) {
|
||||||
|
internalEndDay = parseInt(value);
|
||||||
|
endDay = internalEndDay;
|
||||||
|
notifyChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证并调整日期(当月份变化时)
|
||||||
|
function validateStartDay() {
|
||||||
|
const maxDay = getDaysInMonth(internalStartYear, internalStartMonth).length;
|
||||||
|
if (internalStartDay && internalStartDay > maxDay) {
|
||||||
|
internalStartDay = maxDay;
|
||||||
|
startDay = internalStartDay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEndDay() {
|
||||||
|
const maxDay = getDaysInMonth(internalEndYear, internalEndMonth).length;
|
||||||
|
if (internalEndDay && internalEndDay > maxDay) {
|
||||||
|
internalEndDay = maxDay;
|
||||||
|
endDay = internalEndDay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyChange() {
|
||||||
|
if (onchange && internalStartYear && internalStartMonth && internalStartDay && internalEndYear && internalEndMonth && internalEndDay) {
|
||||||
|
onchange(internalStartYear, internalStartMonth, internalStartDay, internalEndYear, internalEndMonth, internalEndDay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class={cn(
|
||||||
|
"w-[260px] justify-start text-left font-normal",
|
||||||
|
!internalStartYear && "text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||||
|
{displayText()}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content class="w-auto p-4" align="start">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 开始日期 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium text-muted-foreground">开始日期</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={internalStartYear?.toString()}
|
||||||
|
onValueChange={handleStartYearChange}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-[90px]">
|
||||||
|
<span>{internalStartYear || 'YYYY'}</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each years as year}
|
||||||
|
<Select.Item value={year.toString()}>{year}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={internalStartMonth?.toString()}
|
||||||
|
onValueChange={handleStartMonthChange}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-[70px]">
|
||||||
|
<span>{internalStartMonth ? String(internalStartMonth).padStart(2, '0') : 'MM'}</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each months as month}
|
||||||
|
<Select.Item value={month.toString()}>{String(month).padStart(2, '0')}月</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={internalStartDay?.toString()}
|
||||||
|
onValueChange={handleStartDayChange}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-[70px]">
|
||||||
|
<span>{internalStartDay ? String(internalStartDay).padStart(2, '0') : 'DD'}</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each startDays as day}
|
||||||
|
<Select.Item value={day.toString()}>{String(day).padStart(2, '0')}日</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结束日期 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium text-muted-foreground">结束日期</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={internalEndYear?.toString()}
|
||||||
|
onValueChange={handleEndYearChange}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-[90px]">
|
||||||
|
<span>{internalEndYear || 'YYYY'}</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each years as year}
|
||||||
|
<Select.Item value={year.toString()}>{year}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={internalEndMonth?.toString()}
|
||||||
|
onValueChange={handleEndMonthChange}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-[70px]">
|
||||||
|
<span>{internalEndMonth ? String(internalEndMonth).padStart(2, '0') : 'MM'}</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each months as month}
|
||||||
|
<Select.Item value={month.toString()}>{String(month).padStart(2, '0')}月</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={internalEndDay?.toString()}
|
||||||
|
onValueChange={handleEndDayChange}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-[70px]">
|
||||||
|
<span>{internalEndDay ? String(internalEndDay).padStart(2, '0') : 'DD'}</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each endDays as day}
|
||||||
|
<Select.Item value={day.toString()}>{String(day).padStart(2, '0')}日</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fetchBills, checkHealth, type CleanedBill } from '$lib/api';
|
import { fetchBills, fetchMonthlyStats, checkHealth, type CleanedBill, type MonthlyStat } from '$lib/api';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
|
import { MonthRangePicker } from '$lib/components/ui/month-range-picker';
|
||||||
import { formatLocalDate } from '$lib/utils';
|
|
||||||
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
|
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
|
||||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||||
@@ -25,7 +24,6 @@
|
|||||||
// 数据处理服务
|
// 数据处理服务
|
||||||
import {
|
import {
|
||||||
calculateCategoryStats,
|
calculateCategoryStats,
|
||||||
calculateMonthlyStats,
|
|
||||||
calculateDailyExpenseData,
|
calculateDailyExpenseData,
|
||||||
calculateTotalStats,
|
calculateTotalStats,
|
||||||
calculatePieChartData,
|
calculatePieChartData,
|
||||||
@@ -37,15 +35,17 @@
|
|||||||
// 分类数据
|
// 分类数据
|
||||||
import { categories as allCategories } from '$lib/data/categories';
|
import { categories as allCategories } from '$lib/data/categories';
|
||||||
|
|
||||||
// 计算默认日期范围(本月)
|
// 计算默认日期范围(本月1日到今天)
|
||||||
function getDefaultDates() {
|
function getDefaultDates() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const year = today.getFullYear();
|
return {
|
||||||
const month = today.getMonth();
|
startYear: today.getFullYear(),
|
||||||
|
startMonth: today.getMonth() + 1,
|
||||||
const startDate = formatLocalDate(new Date(year, month, 1));
|
startDay: 1,
|
||||||
const endDate = formatLocalDate(today);
|
endYear: today.getFullYear(),
|
||||||
return { startDate, endDate };
|
endMonth: today.getMonth() + 1,
|
||||||
|
endDay: today.getDate(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const defaultDates = getDefaultDates();
|
const defaultDates = getDefaultDates();
|
||||||
|
|
||||||
@@ -53,12 +53,22 @@
|
|||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
let records: CleanedBill[] = $state([]);
|
let records: CleanedBill[] = $state([]);
|
||||||
|
let monthlyStats: MonthlyStat[] = $state([]); // 月度统计(全部数据)
|
||||||
let isDemo = $state(false);
|
let isDemo = $state(false);
|
||||||
let serverAvailable = $state(true);
|
let serverAvailable = $state(true);
|
||||||
|
|
||||||
// 时间范围筛选 - 初始化为默认值
|
// 日期范围筛选 - 初始化为默认值
|
||||||
let startDate: string = $state(defaultDates.startDate);
|
let startYear = $state(defaultDates.startYear);
|
||||||
let endDate: string = $state(defaultDates.endDate);
|
let startMonth = $state(defaultDates.startMonth);
|
||||||
|
let startDay = $state(defaultDates.startDay);
|
||||||
|
let endYear = $state(defaultDates.endYear);
|
||||||
|
let endMonth = $state(defaultDates.endMonth);
|
||||||
|
let endDay = $state(defaultDates.endDay);
|
||||||
|
|
||||||
|
// 辅助函数:将年月日转换为日期字符串
|
||||||
|
function toDateString(year: number, month: number, day: number): string {
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 将 CleanedBill 转换为分析服务需要的格式
|
// 将 CleanedBill 转换为分析服务需要的格式
|
||||||
function toAnalysisRecords(bills: CleanedBill[]) {
|
function toAnalysisRecords(bills: CleanedBill[]) {
|
||||||
@@ -79,7 +89,6 @@
|
|||||||
// 派生分析数据
|
// 派生分析数据
|
||||||
let analysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(records));
|
let analysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(records));
|
||||||
let categoryStats = $derived(calculateCategoryStats(analysisRecords));
|
let categoryStats = $derived(calculateCategoryStats(analysisRecords));
|
||||||
let monthlyStats = $derived(calculateMonthlyStats(analysisRecords));
|
|
||||||
let dailyExpenseData = $derived(calculateDailyExpenseData(analysisRecords));
|
let dailyExpenseData = $derived(calculateDailyExpenseData(analysisRecords));
|
||||||
let totalStats = $derived(calculateTotalStats(analysisRecords));
|
let totalStats = $derived(calculateTotalStats(analysisRecords));
|
||||||
let pieChartData = $derived(calculatePieChartData(categoryStats, totalStats.expense));
|
let pieChartData = $derived(calculatePieChartData(categoryStats, totalStats.expense));
|
||||||
@@ -113,11 +122,48 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取账单数据(带时间范围筛选)
|
// 并行获取数据:筛选后的账单 + 全部月度统计
|
||||||
|
const [billsResponse, monthlyResponse] = await Promise.all([
|
||||||
|
fetchBills({
|
||||||
|
page_size: 10000,
|
||||||
|
start_date: toDateString(startYear, startMonth, startDay),
|
||||||
|
end_date: toDateString(endYear, endMonth, endDay),
|
||||||
|
}),
|
||||||
|
fetchMonthlyStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 处理账单数据
|
||||||
|
if (billsResponse.result && billsResponse.data) {
|
||||||
|
records = billsResponse.data.bills || [];
|
||||||
|
if (records.length === 0) {
|
||||||
|
errorMessage = '暂无账单数据';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage = billsResponse.message || '加载失败';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理月度统计数据
|
||||||
|
if (monthlyResponse.result && monthlyResponse.data) {
|
||||||
|
monthlyStats = monthlyResponse.data;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage = err instanceof Error ? err.message : '加载失败';
|
||||||
|
serverAvailable = false;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期变化时重新加载(只加载筛选后的数据,月度统计不变)
|
||||||
|
async function onDateChange() {
|
||||||
|
if (!isDemo) {
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = '';
|
||||||
|
try {
|
||||||
const response = await fetchBills({
|
const response = await fetchBills({
|
||||||
page_size: 10000,
|
page_size: 10000,
|
||||||
start_date: startDate || undefined,
|
start_date: toDateString(startYear, startMonth, startDay),
|
||||||
end_date: endDate || undefined,
|
end_date: toDateString(endYear, endMonth, endDay),
|
||||||
});
|
});
|
||||||
if (response.result && response.data) {
|
if (response.result && response.data) {
|
||||||
records = response.data.bills || [];
|
records = response.data.bills || [];
|
||||||
@@ -129,17 +175,10 @@
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMessage = err instanceof Error ? err.message : '加载失败';
|
errorMessage = err instanceof Error ? err.message : '加载失败';
|
||||||
serverAvailable = false;
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 日期变化时重新加载
|
|
||||||
function onDateChange() {
|
|
||||||
if (!isDemo) {
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadDemoData() {
|
function loadDemoData() {
|
||||||
@@ -170,10 +209,14 @@
|
|||||||
📊 示例数据
|
📊 示例数据
|
||||||
</Badge>
|
</Badge>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- 时间范围筛选 -->
|
<!-- 日期范围筛选 -->
|
||||||
<DateRangePicker
|
<MonthRangePicker
|
||||||
bind:startDate
|
bind:startYear
|
||||||
bind:endDate
|
bind:startMonth
|
||||||
|
bind:startDay
|
||||||
|
bind:endYear
|
||||||
|
bind:endMonth
|
||||||
|
bind:endDay
|
||||||
onchange={onDateChange}
|
onchange={onDateChange}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
47
web/src/routes/health/+server.ts
Normal file
47
web/src/routes/health/+server.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
// 服务端使用 Docker 内部地址,默认使用 localhost
|
||||||
|
const API_URL = env.API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
// 获取版本号(优先从环境变量,其次从 package.json)
|
||||||
|
function getVersion(): string {
|
||||||
|
// 优先使用环境变量
|
||||||
|
if (process.env.npm_package_version) {
|
||||||
|
return process.env.npm_package_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到默认值
|
||||||
|
return '0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_VERSION = getVersion();
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ fetch }) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/health`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
status: data.status || 'ok',
|
||||||
|
version: APP_VERSION,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
status: 'error',
|
||||||
|
version: APP_VERSION,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user