fix(ui): 修复全局时区偏移问题与日期控件手动输入崩溃 Bug

This commit is contained in:
kennethcheng 2026-04-28 16:19:41 +08:00
parent 110e75f0a1
commit bf57002313
6 changed files with 135 additions and 5 deletions

View File

@ -33,3 +33,6 @@
## UX 与全局交互 (UI/UX) ## UX 与全局交互 (UI/UX)
- 引入 `sonner` 构建全局 Toast 消息通知系统覆盖行情同步、CRUD 操作的成功与异常提示。 - 引入 `sonner` 构建全局 Toast 消息通知系统覆盖行情同步、CRUD 操作的成功与异常提示。
- 重构 `<SyncButton />` 并将其提升至 Dashboard 首页,实现总资产大盘的全局一键实盘刷新。 - 重构 `<SyncButton />` 并将其提升至 Dashboard 首页,实现总资产大盘的全局一键实盘刷新。
## 修复记录
- 修复了全局时区偏移问题,并解决了日期控件手动输入导致的崩溃 Bug。在 `src/libs/utils.ts` 中新增 `nowInShanghai()`、`formatDateForDatetimeLocal()` 和 `parseDateTimeLocalToUTC_v2()` 函数,强制使用 `Asia/Shanghai` (UTC+8) 时区。所有 `new Date()` 调用(包括 Server Actions均已对齐至东八区。日期选择器增加字符串格式校验与解析逻辑避免 `Invalid time value` 错误。

21
package-lock.json generated
View File

@ -17,6 +17,7 @@
"big.js": "^7.0.1", "big.js": "^7.0.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns-tz": "^3.2.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"lucide-react": "^1.11.0", "lucide-react": "^1.11.0",
@ -4545,6 +4546,26 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-tz": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
"license": "MIT",
"peerDependencies": {
"date-fns": "^3.0.0 || ^4.0.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",

View File

@ -21,6 +21,7 @@
"big.js": "^7.0.1", "big.js": "^7.0.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns-tz": "^3.2.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"lucide-react": "^1.11.0", "lucide-react": "^1.11.0",

View File

@ -4,6 +4,7 @@ import { db } from '@/db';
import { exchangeRates } from '@/db/schema'; import { exchangeRates } from '@/db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { nowInShanghai } from '@/libs/utils';
const updateExchangeRateSchema = z.object({ const updateExchangeRateSchema = z.object({
from: z.string().min(1).max(10), from: z.string().min(1).max(10),
@ -33,7 +34,7 @@ export async function updateExchangeRate(
target: [exchangeRates.fromCurrency, exchangeRates.toCurrency], target: [exchangeRates.fromCurrency, exchangeRates.toCurrency],
set: { set: {
rate: validation.data.rate, rate: validation.data.rate,
updatedAt: new Date(), updatedAt: nowInShanghai(),
}, },
}); });

View File

@ -35,6 +35,7 @@ import {
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { createTransaction } from '@/actions/transaction'; import { createTransaction } from '@/actions/transaction';
import { formatDateForDatetimeLocal, parseDateTimeLocalToUTC_v2 } from '@/libs/utils';
const addTransactionSchema = z.object({ const addTransactionSchema = z.object({
assetId: z.string().uuid('请选择一个资产'), assetId: z.string().uuid('请选择一个资产'),
@ -258,7 +259,7 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) {
)} )}
/> />
</div> </div>
<FormField <FormField
control={form.control} control={form.control}
name="executedAt" name="executedAt"
render={({ field }) => ( render={({ field }) => (
@ -269,11 +270,41 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) {
type="datetime-local" type="datetime-local"
{...field} {...field}
onChange={(e) => { onChange={(e) => {
field.onChange(new Date(e.target.value)); const value = e.target.value;
if (!value || value.trim() === '') {
field.onChange(null);
return;
}
const [datePart, timePart] = value.split('T');
if (!datePart) {
field.onChange(null);
return;
}
const [yearStr, monthStr, dayStr] = datePart.split('-');
const year = parseInt(yearStr, 10);
const month = parseInt(monthStr, 10) - 1;
const day = parseInt(dayStr, 10);
if (isNaN(year) || isNaN(month) || isNaN(day)) {
field.onChange(null);
return;
}
let hours = 0;
let minutes = 0;
if (timePart) {
const timeParts = timePart.split(':');
hours = parseInt(timeParts[0], 10) || 0;
minutes = parseInt(timeParts[1], 10) || 0;
}
const parsedDate = parseDateTimeLocalToUTC_v2(year, month, day, hours, minutes);
if (isNaN(parsedDate.getTime())) {
field.onChange(null);
return;
}
field.onChange(parsedDate);
}} }}
value={ value={
field.value field.value
? new Date(field.value).toISOString().slice(0, 16) ? formatDateForDatetimeLocal(field.value)
: '' : ''
} }
/> />

View File

@ -1,6 +1,79 @@
import { type ClassValue, clsx } from "clsx" import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { toZonedTime, fromZonedTime } from "date-fns-tz"
const TIMEZONE = "Asia/Shanghai"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function nowInShanghai(): Date {
const now = new Date()
const utcStr = now.toLocaleString("en-US", { timeZone: "UTC" })
const utcDate = new Date(utcStr)
const shanghaiOffset = getTimezoneOffset("Asia/Shanghai")
const utcOffset = 0
return new Date(utcDate.getTime() + (shanghaiOffset - utcOffset))
}
export function formatDateForDatetimeLocal(date: Date): string {
const zoned = toZonedTime(date, TIMEZONE)
const year = zoned.getFullYear()
const month = String(zoned.getMonth() + 1).padStart(2, "0")
const day = String(zoned.getDate()).padStart(2, "0")
const hours = String(zoned.getHours()).padStart(2, "0")
const minutes = String(zoned.getMinutes()).padStart(2, "0")
return `${year}-${month}-${day}T${hours}:${minutes}`
}
function getTimezoneOffset(timezone: string): number {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
timeZoneName: "longOffset",
})
const parts = formatter.formatToParts(new Date())
const tzPart = parts.find((p) => p.type === "timeZoneName")
if (!tzPart) return 0
const match = tzPart.value.match(/([+-]?\d{1,2}):?(\d{2})/)
if (!match) return 0
const hours = parseInt(match[1], 10)
const minutes = parseInt(match[2], 10)
return (hours * 60 + minutes) * 60 * 1000
}
export function parseDateTimeLocalToUTC(value: string): Date | null {
if (!value) return null
const zoned = fromZonedTime(value, TIMEZONE)
return zoned
}
export function parseDateTimeLocalToUTC_v2(year: number, month: number, day: number, hours: number, minutes: number): Date {
const shanghaiTime = new Date()
shanghaiTime.setFullYear(year)
shanghaiTime.setMonth(month)
shanghaiTime.setDate(day)
shanghaiTime.setHours(hours, minutes, 0, 0)
const shanghaiOffsetMs = getShanghaiOffsetMs(shanghaiTime)
const utcMs = shanghaiTime.getTime() - shanghaiOffsetMs
return new Date(utcMs)
}
function getShanghaiOffsetMs(date: Date): number {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Shanghai",
timeZoneName: "short",
})
const parts = formatter.formatToParts(date)
const tzPart = parts.find((p) => p.type === "timeZoneName")
if (!tzPart) return 8 * 60 * 60 * 1000
const match = tzPart.value.match(/([+-])(\d{2}):?(\d{2})/)
if (!match) return 8 * 60 * 60 * 1000
const sign = match[1] === "+" ? 1 : -1
const offsetHours = parseInt(match[2], 10)
const offsetMinutes = parseInt(match[3], 10)
return sign * (offsetHours * 60 + offsetMinutes) * 60 * 1000
}