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