fix: 修复账单时间显示为UTC时区问题,改为本地时间
- 新增 LocalTime 自定义类型,JSON序列化输出本地时间格式 - 修改 CleanedBill.Time 字段类型为 LocalTime - 更新 parseTime 函数返回 LocalTime 类型 - 前端添加 formatDateTime 工具函数(兼容处理) - 版本号更新至 1.0.2
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。
|
一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -260,6 +260,13 @@ python server.py
|
|||||||
|
|
||||||
## 📋 版本历史
|
## 📋 版本历史
|
||||||
|
|
||||||
|
### v1.0.2 (2026-01-11)
|
||||||
|
|
||||||
|
- 🐛 修复账单时间显示为 UTC 时区的问题,现在正确显示本地时间
|
||||||
|
- 🐛 修复微信账单金额解析问题(半角¥符号)
|
||||||
|
- ✨ 新增月度统计 API,支持获取所有月份的收支数据
|
||||||
|
- 🔧 优化数据分析页面月份选择器
|
||||||
|
|
||||||
### v1.0.1 (2026-01-11)
|
### v1.0.1 (2026-01-11)
|
||||||
|
|
||||||
- 🐛 修复智能复核页面空数据显示错误
|
- 🐛 修复智能复核页面空数据显示错误
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# BillAI 服务器配置文件
|
# BillAI 服务器配置文件
|
||||||
|
|
||||||
# 应用版本
|
# 应用版本
|
||||||
version: "1.0.0"
|
version: "1.0.2"
|
||||||
|
|
||||||
# 服务配置
|
# 服务配置
|
||||||
server:
|
server:
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func CreateManualBills(c *gin.Context) {
|
|||||||
BillType: "manual",
|
BillType: "manual",
|
||||||
TransactionID: "", // 手动账单不设置交易ID
|
TransactionID: "", // 手动账单不设置交易ID
|
||||||
MerchantOrderNo: "",
|
MerchantOrderNo: "",
|
||||||
Time: t,
|
Time: model.LocalTime(t),
|
||||||
Category: input.Category,
|
Category: input.Category,
|
||||||
Merchant: input.Merchant,
|
Merchant: input.Merchant,
|
||||||
Description: input.Description,
|
Description: input.Description,
|
||||||
|
|||||||
@@ -3,9 +3,70 @@ package model
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/bsontype"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LocalTime 自定义时间类型,JSON 序列化时输出本地时间格式
|
||||||
|
type LocalTime time.Time
|
||||||
|
|
||||||
|
// MarshalJSON 实现 json.Marshaler 接口,输出本地时间格式
|
||||||
|
func (t LocalTime) MarshalJSON() ([]byte, error) {
|
||||||
|
tt := time.Time(t)
|
||||||
|
if tt.IsZero() {
|
||||||
|
return []byte(`""`), nil
|
||||||
|
}
|
||||||
|
// 输出格式: "2006-01-02 15:04:05"
|
||||||
|
return []byte(`"` + tt.Local().Format("2006-01-02 15:04:05") + `"`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON 实现 json.Unmarshaler 接口
|
||||||
|
func (t *LocalTime) UnmarshalJSON(data []byte) error {
|
||||||
|
s := string(data)
|
||||||
|
if s == `""` || s == "null" {
|
||||||
|
*t = LocalTime(time.Time{})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 去掉引号
|
||||||
|
s = s[1 : len(s)-1]
|
||||||
|
|
||||||
|
// 尝试多种格式解析
|
||||||
|
formats := []string{
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04:05Z07:00",
|
||||||
|
"2006-01-02T15:04:05Z",
|
||||||
|
"2006-01-02",
|
||||||
|
}
|
||||||
|
for _, format := range formats {
|
||||||
|
if parsed, err := time.ParseInLocation(format, s, time.Local); err == nil {
|
||||||
|
*t = LocalTime(parsed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalBSONValue 实现 bson.ValueMarshaler 接口
|
||||||
|
func (t LocalTime) MarshalBSONValue() (bsontype.Type, []byte, error) {
|
||||||
|
return bson.MarshalValue(time.Time(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalBSONValue 实现 bson.ValueUnmarshaler 接口
|
||||||
|
func (t *LocalTime) UnmarshalBSONValue(btype bsontype.Type, data []byte) error {
|
||||||
|
var tt time.Time
|
||||||
|
if err := bson.UnmarshalValue(btype, data, &tt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*t = LocalTime(tt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time 返回标准 time.Time
|
||||||
|
func (t LocalTime) Time() time.Time {
|
||||||
|
return time.Time(t)
|
||||||
|
}
|
||||||
|
|
||||||
// RawBill 原始账单记录(存储上传的原始数据)
|
// RawBill 原始账单记录(存储上传的原始数据)
|
||||||
type RawBill struct {
|
type RawBill struct {
|
||||||
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
||||||
@@ -23,7 +84,7 @@ type CleanedBill struct {
|
|||||||
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
|
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
|
||||||
TransactionID string `bson:"transaction_id" json:"transaction_id"` // 交易订单号(用于去重)
|
TransactionID string `bson:"transaction_id" json:"transaction_id"` // 交易订单号(用于去重)
|
||||||
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"` // 商家订单号(用于去重)
|
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"` // 商家订单号(用于去重)
|
||||||
Time time.Time `bson:"time" json:"time"` // 交易时间
|
Time LocalTime `bson:"time" json:"time"` // 交易时间(本地时间格式)
|
||||||
Category string `bson:"category" json:"category"` // 交易分类
|
Category string `bson:"category" json:"category"` // 交易分类
|
||||||
Merchant string `bson:"merchant" json:"merchant"` // 交易对方
|
Merchant string `bson:"merchant" json:"merchant"` // 交易对方
|
||||||
Description string `bson:"description" json:"description"` // 商品说明
|
Description string `bson:"description" json:"description"` // 商品说明
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ func (r *Repository) CheckCleanedDuplicate(bill *model.CleanedBill) (bool, error
|
|||||||
} else {
|
} else {
|
||||||
// 回退到 时间+金额+商户 组合判断
|
// 回退到 时间+金额+商户 组合判断
|
||||||
filter = bson.M{
|
filter = bson.M{
|
||||||
"time": bill.Time,
|
"time": bill.Time.Time(), // 转换为 time.Time 用于 MongoDB 查询
|
||||||
"amount": bill.Amount,
|
"amount": bill.Amount,
|
||||||
"merchant": bill.Merchant,
|
"merchant": bill.Merchant,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func checkDuplicate(ctx context.Context, bill *model.CleanedBill) bool {
|
|||||||
} else {
|
} else {
|
||||||
// 回退到 时间+金额+商户 组合判断
|
// 回退到 时间+金额+商户 组合判断
|
||||||
filter = bson.M{
|
filter = bson.M{
|
||||||
"time": bill.Time,
|
"time": bill.Time.Time(), // 转换为 time.Time 用于 MongoDB 查询
|
||||||
"amount": bill.Amount,
|
"amount": bill.Amount,
|
||||||
"merchant": bill.Merchant,
|
"merchant": bill.Merchant,
|
||||||
}
|
}
|
||||||
@@ -489,11 +489,11 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseTime 解析时间字符串
|
// parseTime 解析时间字符串
|
||||||
// 使用本地时区解析,避免 UTC 时区问题
|
// 使用本地时区解析,返回 model.LocalTime 类型
|
||||||
func parseTime(s string) time.Time {
|
func parseTime(s string) model.LocalTime {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return time.Time{}
|
return model.LocalTime(time.Time{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试多种时间格式(使用本地时区)
|
// 尝试多种时间格式(使用本地时区)
|
||||||
@@ -508,11 +508,11 @@ func parseTime(s string) time.Time {
|
|||||||
|
|
||||||
for _, format := range formats {
|
for _, format := range formats {
|
||||||
if t, err := time.ParseInLocation(format, s, time.Local); err == nil {
|
if t, err := time.ParseInLocation(format, s, time.Local); err == nil {
|
||||||
return t
|
return model.LocalTime(t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return time.Time{}
|
return model.LocalTime(time.Time{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseAmount 解析金额字符串
|
// parseAmount 解析金额字符串
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -16,6 +16,31 @@ export function formatLocalDate(date: Date): string {
|
|||||||
return `${y}-${m}-${d}`;
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 ISO 时间字符串(UTC)转换为本地时区的格式化字符串
|
||||||
|
* 用于显示账单交易时间
|
||||||
|
* @param isoString - ISO 格式的时间字符串,如 "2026-01-10T05:12:13Z"
|
||||||
|
* @returns 本地时间格式,如 "2026-01-10 13:12:13"
|
||||||
|
*/
|
||||||
|
export function formatDateTime(isoString: string): string {
|
||||||
|
if (!isoString) return '';
|
||||||
|
try {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
if (isNaN(date.getTime())) return isoString;
|
||||||
|
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
const h = String(date.getHours()).padStart(2, '0');
|
||||||
|
const min = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${y}-${m}-${d} ${h}:${min}:${s}`;
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
|
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
|
||||||
import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte';
|
import ManualBillInput from '$lib/components/bills/ManualBillInput.svelte';
|
||||||
import { formatLocalDate } from '$lib/utils';
|
import { formatLocalDate, formatDateTime } from '$lib/utils';
|
||||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
@@ -385,7 +385,7 @@
|
|||||||
{#each displayRecords as record}
|
{#each displayRecords as record}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
{record.time}
|
{formatDateTime(record.time)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="hidden xl:table-cell">
|
<Table.Cell class="hidden xl:table-cell">
|
||||||
<Badge variant={record.bill_type === 'manual' ? 'outline' : (record.bill_type === 'alipay' ? 'default' : 'secondary')}>
|
<Badge variant={record.bill_type === 'manual' ? 'outline' : (record.bill_type === 'alipay' ? 'default' : 'secondary')}>
|
||||||
|
|||||||
Reference in New Issue
Block a user