fix(ui): 修复全局时区偏移问题与日期控件手动输入崩溃 Bug
This commit is contained in:
parent
110e75f0a1
commit
bf57002313
@ -32,4 +32,7 @@
|
|||||||
|
|
||||||
## 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
21
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user