统一前端时间按酒店时区显示 #1
@@ -1,14 +1,25 @@
|
|||||||
const configuredReservationHotelId = import.meta.env.VITE_RESERVATION_HOTEL_ID?.trim()
|
const configuredReservationHotelId = import.meta.env.VITE_RESERVATION_HOTEL_ID?.trim()
|
||||||
|
|
||||||
export const reservationHotelId = configuredReservationHotelId || null
|
export const reservationHotelId = configuredReservationHotelId || null
|
||||||
|
export const reservationTimeZone = 'Asia/Bangkok'
|
||||||
|
|
||||||
let reservationHotelIdProvider: (() => string | null | undefined) | null = null
|
let reservationHotelIdProvider: (() => string | null | undefined) | null = null
|
||||||
|
let reservationTimeZoneProvider: (() => string | null | undefined) | null = null
|
||||||
|
|
||||||
export function setReservationHotelIdProvider(provider: (() => string | null | undefined) | null): void {
|
export function setReservationHotelIdProvider(provider: (() => string | null | undefined) | null): void {
|
||||||
reservationHotelIdProvider = provider
|
reservationHotelIdProvider = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setReservationTimeZoneProvider(provider: (() => string | null | undefined) | null): void {
|
||||||
|
reservationTimeZoneProvider = provider
|
||||||
|
}
|
||||||
|
|
||||||
export function getReservationHotelId(): string | null {
|
export function getReservationHotelId(): string | null {
|
||||||
const providedHotelId = reservationHotelIdProvider?.()?.trim()
|
const providedHotelId = reservationHotelIdProvider?.()?.trim()
|
||||||
return providedHotelId || reservationHotelId
|
return providedHotelId || reservationHotelId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getReservationTimeZone(): string {
|
||||||
|
const providedTimeZone = reservationTimeZoneProvider?.()?.trim()
|
||||||
|
return providedTimeZone || reservationTimeZone
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { VueQueryPlugin } from '@tanstack/vue-query'
|
|||||||
import 'primeicons/primeicons.css'
|
import 'primeicons/primeicons.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { setReservationHotelIdProvider } from './config/reservationConfig'
|
import {
|
||||||
|
setReservationHotelIdProvider,
|
||||||
|
setReservationTimeZoneProvider,
|
||||||
|
} from './config/reservationConfig'
|
||||||
import { i18n } from './i18n'
|
import { i18n } from './i18n'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import { setUnauthorizedHandler } from './services/httpClient'
|
import { setUnauthorizedHandler } from './services/httpClient'
|
||||||
@@ -18,6 +21,7 @@ const pinia = createPinia()
|
|||||||
const authStore = useAuthStore(pinia)
|
const authStore = useAuthStore(pinia)
|
||||||
|
|
||||||
setReservationHotelIdProvider(() => authStore.selectedHotelId)
|
setReservationHotelIdProvider(() => authStore.selectedHotelId)
|
||||||
|
setReservationTimeZoneProvider(() => authStore.selectedHotel?.time_zone)
|
||||||
setUnauthorizedHandler(() => {
|
setUnauthorizedHandler(() => {
|
||||||
const currentPath = router.currentRoute.value.fullPath
|
const currentPath = router.currentRoute.value.fullPath
|
||||||
authStore.clearAuth()
|
authStore.clearAuth()
|
||||||
|
|||||||
45
client/src/tests/reservationFormat.spec.ts
Normal file
45
client/src/tests/reservationFormat.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
setReservationTimeZoneProvider,
|
||||||
|
} from '@/config/reservationConfig'
|
||||||
|
import {
|
||||||
|
formatReservationDateRange,
|
||||||
|
formatReservationDateTime,
|
||||||
|
} from '@/utils/reservationFormat'
|
||||||
|
|
||||||
|
describe('reservationFormat', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
setReservationTimeZoneProvider(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats UTC API timestamps in the default hotel time zone', () => {
|
||||||
|
expect(formatReservationDateTime('2026-07-09T05:56:56Z')).toBe('2026-07-09 12:56:56')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats timestamps in the selected hotel time zone', () => {
|
||||||
|
setReservationTimeZoneProvider(() => 'Asia/Tokyo')
|
||||||
|
|
||||||
|
expect(formatReservationDateTime('2026-07-09T05:56:56Z')).toBe('2026-07-09 14:56:56')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors offsets from source timestamps before converting to hotel time', () => {
|
||||||
|
expect(formatReservationDateTime('2026-07-08T09:18:00+08:00')).toBe('2026-07-08 08:18:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default hotel time zone when the selected hotel timezone is invalid', () => {
|
||||||
|
setReservationTimeZoneProvider(() => 'Invalid/Timezone')
|
||||||
|
|
||||||
|
expect(formatReservationDateTime('2026-07-09T05:56:56Z')).toBe('2026-07-09 12:56:56')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back safely for empty or invalid timestamps', () => {
|
||||||
|
expect(formatReservationDateTime(null)).toBe('-')
|
||||||
|
expect(formatReservationDateTime(undefined)).toBe('-')
|
||||||
|
expect(formatReservationDateTime('not-a-date')).toBe('not-a-date')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps hotel business date ranges as local dates without timezone conversion', () => {
|
||||||
|
expect(formatReservationDateRange('2026-07-09', '2026-07-10')).toBe('2026-07-09 / 2026-07-10')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import { getReservationTimeZone, reservationTimeZone } from '@/config/reservationConfig'
|
||||||
|
|
||||||
export function formatReservationDateTime(value: string | null | undefined): string {
|
export function formatReservationDateTime(value: string | null | undefined): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
return value.replace('T', ' ').replace(/\+.*$/, '')
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return formatDateTimeInTimeZone(date, getReservationTimeZone())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatReservationDateRange(arrivalDate: string, departureDate: string): string {
|
export function formatReservationDateRange(arrivalDate: string, departureDate: string): string {
|
||||||
@@ -16,3 +22,26 @@ export function formatReservationCurrency(amount: number, currencyCode: string):
|
|||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(amount)
|
}).format(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTimeInTimeZone(date: Date, timeZone: string): string {
|
||||||
|
try {
|
||||||
|
return formatDateTimeParts(date, timeZone)
|
||||||
|
} catch {
|
||||||
|
return formatDateTimeParts(date, reservationTimeZone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeParts(date: Date, timeZone: string): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
}).formatToParts(date)
|
||||||
|
const valueByType = Object.fromEntries(parts.map((part) => [part.type, part.value]))
|
||||||
|
return `${valueByType.year}-${valueByType.month}-${valueByType.day} ${valueByType.hour}:${valueByType.minute}:${valueByType.second}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -217,23 +217,33 @@
|
|||||||
<dl class="debug-eml-trace-fields">
|
<dl class="debug-eml-trace-fields">
|
||||||
<div v-if="trace.tool_name">
|
<div v-if="trace.tool_name">
|
||||||
<dt>{{ t('debugEml.traceToolName') }}</dt>
|
<dt>{{ t('debugEml.traceToolName') }}</dt>
|
||||||
<dd class="th-code">{{ trace.tool_name }}</dd>
|
<dd class="th-code">
|
||||||
|
{{ trace.tool_name }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="trace.input_summary">
|
<div v-if="trace.input_summary">
|
||||||
<dt>{{ t('debugEml.traceInputSummary') }}</dt>
|
<dt>{{ t('debugEml.traceInputSummary') }}</dt>
|
||||||
<dd class="th-code">{{ trace.input_summary }}</dd>
|
<dd class="th-code">
|
||||||
|
{{ trace.input_summary }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="trace.output_summary">
|
<div v-if="trace.output_summary">
|
||||||
<dt>{{ t('debugEml.traceOutputSummary') }}</dt>
|
<dt>{{ t('debugEml.traceOutputSummary') }}</dt>
|
||||||
<dd class="th-code">{{ trace.output_summary }}</dd>
|
<dd class="th-code">
|
||||||
|
{{ trace.output_summary }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="trace.status">
|
<div v-if="trace.status">
|
||||||
<dt>{{ t('debugEml.traceStatus') }}</dt>
|
<dt>{{ t('debugEml.traceStatus') }}</dt>
|
||||||
<dd class="th-code">{{ trace.status }}</dd>
|
<dd class="th-code">
|
||||||
|
{{ trace.status }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="trace.ts">
|
<div v-if="trace.ts">
|
||||||
<dt>{{ t('debugEml.traceTime') }}</dt>
|
<dt>{{ t('debugEml.traceTime') }}</dt>
|
||||||
<dd class="th-code">{{ trace.ts }}</dd>
|
<dd class="th-code">
|
||||||
|
{{ formatReservationDateTime(trace.ts) }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
@@ -437,6 +447,7 @@ import type {
|
|||||||
DebugEmlSuperAgentRunResult,
|
DebugEmlSuperAgentRunResult,
|
||||||
DebugEmlSuperAgentTraceEvent,
|
DebugEmlSuperAgentTraceEvent,
|
||||||
} from '@/types/debugEml'
|
} from '@/types/debugEml'
|
||||||
|
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||||
|
|
||||||
type DebugEmlStatus = 'idle' | 'uploading' | 'succeeded' | 'failed'
|
type DebugEmlStatus = 'idle' | 'uploading' | 'succeeded' | 'failed'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user