diff --git a/Memory.md b/Memory.md index 58b5dd2..c5b8b0e 100644 --- a/Memory.md +++ b/Memory.md @@ -32,4 +32,7 @@ ## UX 与全局交互 (UI/UX) - 引入 `sonner` 构建全局 Toast 消息通知系统,覆盖行情同步、CRUD 操作的成功与异常提示。 -- 重构 `` 并将其提升至 Dashboard 首页,实现总资产大盘的全局一键实盘刷新。 \ No newline at end of file +- 重构 `` 并将其提升至 Dashboard 首页,实现总资产大盘的全局一键实盘刷新。 + +## 修复记录 +- 修复了全局时区偏移问题,并解决了日期控件手动输入导致的崩溃 Bug。在 `src/libs/utils.ts` 中新增 `nowInShanghai()`、`formatDateForDatetimeLocal()` 和 `parseDateTimeLocalToUTC_v2()` 函数,强制使用 `Asia/Shanghai` (UTC+8) 时区。所有 `new Date()` 调用(包括 Server Actions)均已对齐至东八区。日期选择器增加字符串格式校验与解析逻辑,避免 `Invalid time value` 错误。 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 40fbd77..b6a3238 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "big.js": "^7.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns-tz": "^3.2.0", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "lucide-react": "^1.11.0", @@ -4545,6 +4546,26 @@ "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": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 4ec5c6d..0981652 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "big.js": "^7.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns-tz": "^3.2.0", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "lucide-react": "^1.11.0", diff --git a/src/actions/exchange.ts b/src/actions/exchange.ts index b4060d9..ac827fe 100644 --- a/src/actions/exchange.ts +++ b/src/actions/exchange.ts @@ -4,6 +4,7 @@ import { db } from '@/db'; import { exchangeRates } from '@/db/schema'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; +import { nowInShanghai } from '@/libs/utils'; const updateExchangeRateSchema = z.object({ from: z.string().min(1).max(10), @@ -33,7 +34,7 @@ export async function updateExchangeRate( target: [exchangeRates.fromCurrency, exchangeRates.toCurrency], set: { rate: validation.data.rate, - updatedAt: new Date(), + updatedAt: nowInShanghai(), }, }); diff --git a/src/components/transactions/add-transaction-dialog.tsx b/src/components/transactions/add-transaction-dialog.tsx index 1fc5f21..15ff865 100644 --- a/src/components/transactions/add-transaction-dialog.tsx +++ b/src/components/transactions/add-transaction-dialog.tsx @@ -35,6 +35,7 @@ import { import { Plus } from 'lucide-react'; import { toast } from 'sonner'; import { createTransaction } from '@/actions/transaction'; +import { formatDateForDatetimeLocal, parseDateTimeLocalToUTC_v2 } from '@/libs/utils'; const addTransactionSchema = z.object({ assetId: z.string().uuid('请选择一个资产'), @@ -258,7 +259,7 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) { )} /> - ( @@ -269,11 +270,41 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) { type="datetime-local" {...field} 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={ field.value - ? new Date(field.value).toISOString().slice(0, 16) + ? formatDateForDatetimeLocal(field.value) : '' } /> diff --git a/src/libs/utils.ts b/src/libs/utils.ts index 1a860ee..0d521a9 100644 --- a/src/libs/utils.ts +++ b/src/libs/utils.ts @@ -1,6 +1,79 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" +import { toZonedTime, fromZonedTime } from "date-fns-tz" + +const TIMEZONE = "Asia/Shanghai" export function cn(...inputs: ClassValue[]) { 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 } \ No newline at end of file