From 2bfc49477c0860058d2ff27d8ca40b0c3a1ca31c Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Fri, 9 Oct 2026 20:00:02 +0800 Subject: [PATCH] fix(ops): initialize custom time ranges in local time --- .../ops/components/OpsDashboardHeader.vue | 6 +-- .../OpsDashboardHeader.timezone.spec.ts | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 frontend/src/views/admin/ops/components/__tests__/OpsDashboardHeader.timezone.spec.ts diff --git a/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue b/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue index 35bb9e7a30..5eaef8563e 100644 --- a/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue +++ b/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue @@ -10,7 +10,7 @@ import { adminAPI } from '@/api' import { opsAPI, type OpsDashboardOverview, type OpsMetricThresholds, type OpsRealtimeTrafficSummary } from '@/api/admin/ops' import type { OpsRequestDetailsPreset } from './OpsRequestDetailsModal.vue' import { useAdminSettingsStore } from '@/stores' -import { formatNumber } from '@/utils/format' +import { formatDateTimeLocalInput, formatNumber } from '@/utils/format' import { formatMemorySizeMB } from '../utils/opsFormatters' type RealtimeWindow = '1min' | '5min' | '30min' | '1h' @@ -178,8 +178,8 @@ function handleTimeRangeChange(val: string | number | boolean | null) { // 初始化为最近1小时 const now = new Date() const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) - customStartTimeInput.value = oneHourAgo.toISOString().slice(0, 16) - customEndTimeInput.value = now.toISOString().slice(0, 16) + customStartTimeInput.value = formatDateTimeLocalInput(oneHourAgo.getTime() / 1000) + customEndTimeInput.value = formatDateTimeLocalInput(now.getTime() / 1000) showCustomTimeRangeDialog.value = true } else { emit('update:timeRange', newValue) diff --git a/frontend/src/views/admin/ops/components/__tests__/OpsDashboardHeader.timezone.spec.ts b/frontend/src/views/admin/ops/components/__tests__/OpsDashboardHeader.timezone.spec.ts new file mode 100644 index 0000000000..838a116e2c --- /dev/null +++ b/frontend/src/views/admin/ops/components/__tests__/OpsDashboardHeader.timezone.spec.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import OpsDashboardHeader from '../OpsDashboardHeader.vue' + +vi.mock('@/api', () => ({ adminAPI: { groups: { getAll: vi.fn().mockResolvedValue([]) } } })) +vi.mock('@/api/admin/ops', () => ({ opsAPI: { getRealtimeTrafficSummary: vi.fn().mockResolvedValue({ enabled: true, summary: null }) } })) +vi.mock('@/stores', () => ({ useAdminSettingsStore: () => ({ opsRealtimeMonitoringEnabled: true, setOpsRealtimeMonitoringEnabledLocal: vi.fn() }) })) +vi.mock('vue-i18n', async () => ({ + ...await vi.importActual('vue-i18n'), + useI18n: () => ({ t: (key: string) => key }) +})) +enableAutoUnmount(afterEach) +beforeEach(() => vi.stubEnv('TZ', 'Asia/Shanghai')) +afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs() }) + +async function openCustomRange(now: Date) { + expect(now.getTimezoneOffset()).toBe(-480) + vi.useFakeTimers() + vi.setSystemTime(now) + const wrapper = mount(OpsDashboardHeader, { + props: { platform: '', groupId: null, timeRange: '1h', queryMode: 'auto', loading: false, lastUpdated: null }, + global: { stubs: { + Select: true, HelpTooltip: true, Icon: true, + BaseDialog: { props: ['show'], template: '
' } + } } + }) + await flushPromises() + const selector = wrapper.findAllComponents({ name: 'Select' }).find(select => + select.props('options').some((option: { value: unknown }) => option.value === 'custom') + )! + selector.vm.$emit('update:modelValue', 'custom') + await flushPromises() + return wrapper +} + +describe('ops custom time range timezone', () => { + it.each([ + [14, 23, '2026-10-09T13:23', '2026-10-09T14:23'], + [0, 15, '2026-10-08T23:15', '2026-10-09T00:15'] + ] as const)('prefills the previous local hour at %i:%i', async (hour, minute, start, end) => { + const wrapper = await openCustomRange(new Date(2026, 9, 9, hour, minute)) + const inputs = wrapper.findAll('input[type="datetime-local"]') + expect(inputs.map(input => input.element.value)).toEqual([start, end]) + }) + + it('submits the previous real hour without applying the timezone offset twice', async () => { + const now = new Date(2026, 9, 9, 14, 23) + const wrapper = await openCustomRange(now) + await wrapper.findAll('button').find(button => button.text() === 'common.confirm')!.trigger('click') + expect(wrapper.emitted('update:customTimeRange')).toEqual([ + [new Date(now.getTime() - 3600000).toISOString(), now.toISOString()] + ]) + }) +})