diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx index 4d980bb6..8b9317ae 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx @@ -5,7 +5,7 @@ import { MemoryRouter } from 'react-router-dom'; import type { HistoryDataResponse, HistorySeriesResponse } from '../../api/types'; import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext'; import { ROUTER_FUTURE } from '../routing/routerConfig'; -import HistoryPage, { formatHistoryDateTime } from './HistoryPage'; +import HistoryPage, { formatHistoryDateTime, historyDateTimePickerRange, historyPresetWindow } from './HistoryPage'; const mocks = vi.hoisted(() => ({ historyMetricCatalog: vi.fn(), @@ -60,6 +60,18 @@ test('renders protocol timestamps in Asia/Shanghai while preserving local values expect(formatHistoryDateTime('2026-07-18 02:33:00')).toBe('2026-07-18 02:33:00'); }); +test('normalizes Semi date-time ranges and produces stable quick windows', () => { + const now = new Date(2026, 6, 19, 14, 30); + expect(historyDateTimePickerRange([], ['2026-07-19 08:15', '2026-07-19 14:30'])).toEqual({ + dateFrom: '2026-07-19T08:15', + dateTo: '2026-07-19T14:30' + }); + expect(historyPresetWindow('today', now)).toEqual({ dateFrom: '2026-07-19T00:00', dateTo: '2026-07-19T14:30' }); + expect(historyPresetWindow('yesterday', now)).toEqual({ dateFrom: '2026-07-18T00:00', dateTo: '2026-07-18T23:59' }); + expect(historyPresetWindow('last6Hours', now)).toEqual({ dateFrom: '2026-07-19T08:30', dateTo: '2026-07-19T14:30' }); + expect(historyPresetWindow('last24Hours', now)).toEqual({ dateFrom: '2026-07-18T14:30', dateTo: '2026-07-19T14:30' }); +}); + test('preserves the exact monitor return after changing history filters', async () => { mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] }); mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of')); @@ -122,6 +134,11 @@ test('clears stale rows, charts, and evidence as soon as the history scope chang expect(view.container.querySelector('.v2-history-discovery-shell')).toContainElement(screen.getByLabelText('历史数据查询操作')); expect(screen.getByLabelText('历史数据查询操作')).toHaveTextContent('遥测证据检索'); expect(screen.getByLabelText('历史数据查询操作')).toHaveTextContent('上海时区'); + expect(view.container.querySelector('.v2-history-date-time-range.semi-datepicker')).toBeInTheDocument(); + expect(screen.getByRole('group', { name: '快捷时间范围' })).toHaveClass('semi-button-group'); + expect(screen.getByPlaceholderText('开始时间')).toHaveValue('2026-07-16 00:00'); + expect(screen.getByPlaceholderText('结束时间')).toHaveValue('2026-07-16 05:00'); + expect(view.container.querySelector('input[type="datetime-local"]')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-history-summary-rail')).toHaveClass('v2-workspace-metric-rail', 'is-queue'); expect(view.container.querySelector('.v2-history-summary-rail .v2-workspace-metric-list')).toHaveAttribute('aria-label', '历史数据查询统计信息'); expect(view.container.querySelectorAll('.v2-history-summary-rail .v2-workspace-metric-list > span')).toHaveLength(4); @@ -218,8 +235,9 @@ test('uses one focused Semi bottom SideSheet for the complete mobile history que expect(document.querySelector('.v2-history-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom'); expect(document.querySelector('.v2-history-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 700px)' }); expect(within(dialog).getByPlaceholderText('车牌 / VIN,多台用逗号分隔')).toBeInTheDocument(); - expect(within(dialog).getByLabelText('开始时间')).toBeInTheDocument(); - expect(within(dialog).getByLabelText('结束时间')).toBeInTheDocument(); + expect(within(dialog).getByPlaceholderText('开始时间')).toBeInTheDocument(); + expect(within(dialog).getByPlaceholderText('结束时间')).toBeInTheDocument(); + expect(within(dialog).getByRole('group', { name: '快捷时间范围' })).toHaveClass('semi-button-group'); expect(within(dialog).getByRole('combobox', { name: '数据类型' })).toBeInTheDocument(); expect(within(dialog).getByRole('combobox', { name: '数据来源' })).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole('button', { name: '重置条件' })); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx index ffa8711b..680c8772 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; import { IconCalendar, IconChevronRight, IconClose, IconDownload, IconMapPin, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; -import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Empty, Input, Progress, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; +import { Button, ButtonGroup, Card, CardGroup, Checkbox, DatePicker, Descriptions, Dropdown, Empty, Input, Progress, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { FormEvent, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; @@ -112,9 +112,45 @@ export function formatHistoryDateTime(value: string) { } function currentHistoryWindow() { - const now = new Date(); const pad = (value: number) => String(value).padStart(2, '0'); - const day = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; - return { dateFrom: `${day}T00:00`, dateTo: `${day}T${pad(now.getHours())}:${pad(now.getMinutes())}` }; + return historyPresetWindow('today'); +} + +type HistoryRangePreset = 'today' | 'yesterday' | 'last6Hours' | 'last24Hours'; + +function localHistoryMinute(value: Date) { + const pad = (part: number) => String(part).padStart(2, '0'); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`; +} + +export function historyPresetWindow(preset: HistoryRangePreset, now = new Date()) { + if (preset === 'yesterday') { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0); + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59); + return { dateFrom: localHistoryMinute(start), dateTo: localHistoryMinute(end) }; + } + if (preset === 'last6Hours' || preset === 'last24Hours') { + const hours = preset === 'last6Hours' ? 6 : 24; + return { dateFrom: localHistoryMinute(new Date(now.getTime() - hours * 60 * 60_000)), dateTo: localHistoryMinute(now) }; + } + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0); + return { dateFrom: localHistoryMinute(start), dateTo: localHistoryMinute(now) }; +} + +export function historyDateTimePickerRange( + dateValue?: Date | Date[] | string | string[], + formattedValue?: string | string[] | Date | Date[] +) { + const values = Array.isArray(formattedValue) && formattedValue.length === 2 + ? formattedValue + : Array.isArray(dateValue) && dateValue.length === 2 ? dateValue : []; + if (values.length !== 2) return; + const normalized = values.map((value) => { + if (value instanceof Date) return localHistoryMinute(value); + if (typeof value === 'number') return localHistoryMinute(new Date(value)); + return String(value).trim().replace(' ', 'T').slice(0, 16); + }); + if (normalized.some((value) => !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value))) return; + return { dateFrom: normalized[0], dateTo: normalized[1] }; } function CreateExportButton({ request, disabled }: { request: HistoryExportRequest; disabled: boolean }) { @@ -411,6 +447,12 @@ export default function HistoryPage() { setSelectedRowRef({ scope: dataScope, id: '' }); }, [dataScope]); const mobileFiltersOpen = mobileLayout && !filtersCollapsed; + const historyRangePresets = useMemo(() => [ + { key: 'today' as const, label: '今天', range: historyPresetWindow('today') }, + { key: 'yesterday' as const, label: '昨天', range: historyPresetWindow('yesterday') }, + { key: 'last6Hours' as const, label: '近 6 小时', range: historyPresetWindow('last6Hours') }, + { key: 'last24Hours' as const, label: '近 24 小时', range: historyPresetWindow('last24Hours') } + ], []); const applyDraft = () => { const parsed = parseHistoryKeywords(draft.keywords); @@ -476,7 +518,32 @@ export default function HistoryPage() { ]; const historyFilterFields = <> -
+ >; @@ -536,7 +603,7 @@ export default function HistoryPage() { > } @@ -575,6 +642,7 @@ export default function HistoryPage() { > : null : <> } onClick={() => setTrendExpanded((value) => !value)}>{trendExpanded ? '收起趋势' : '聚合趋势'} } onClick={() => setColumnSettingsOpen((value) => !value)}>列设置 + {exportAllowed ?