setDraft((current) => ({ ...current, ...range }))}
+ presets={historyRangePresets}
+ />
>;
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx
index 4e9b9918..06b52bb7 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx
@@ -154,6 +154,9 @@ test('merges duplicate vehicle sources and keeps the active date shortcut visibl
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
+ expect(document.querySelector('.v2-track-date-time-picker.semi-datepicker')).toBeInTheDocument();
+ expect(screen.getByRole('group', { name: '轨迹快捷时间范围' })).toHaveClass('semi-button-group');
+ expect(document.querySelector('input[type="datetime-local"]')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '今天' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: '昨天' }));
expect(screen.getByRole('button', { name: '昨天' })).toHaveAttribute('aria-pressed', 'true');
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
index 0ce7d5f2..c6a6daa6 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
@@ -18,6 +18,7 @@ import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
+import { WorkspaceDateTimeRange, workspaceDateTimePresetWindow } from '../shared/WorkspaceDateTimeRange';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
@@ -249,7 +250,11 @@ const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading,
onCollapse: () => void;
}) {
const activePreset = trackPreset(draft);
- const choosePreset = (daysBack: number) => onDraft({ ...draft, ...trackWindow(daysBack, daysBack > 0) });
+ const trackRangePresets = useMemo(() => [
+ { key: 'today', label: '今天', range: workspaceDateTimePresetWindow('today') },
+ { key: 'yesterday', label: '昨天', range: workspaceDateTimePresetWindow('yesterday') },
+ { key: 'three-days', label: '近 3 天', range: workspaceDateTimePresetWindow('last3Days') }
+ ], []).map((preset) => ({ ...preset, active: activePreset === preset.key }));
const appliedWindow = `${draft.dateFrom.replace('T', ' ').slice(5)} – ${draft.dateTo.replace('T', ' ').slice(5)}`;
const appliedSource = draft.protocol ? protocolDisplayLabel(draft.protocol) : '自动来源';
const contextTitle = queryCollapsed ? track?.plate || draft.keyword || '轨迹查询' : '轨迹查询';
@@ -279,12 +284,16 @@ const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading,
/> : null}
{!queryCollapsed ? : null}
diff --git a/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDateTimeRange.test.tsx b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDateTimeRange.test.tsx
new file mode 100644
index 00000000..4d49f990
--- /dev/null
+++ b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDateTimeRange.test.tsx
@@ -0,0 +1,43 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { expect, test, vi } from 'vitest';
+import {
+ normalizeWorkspaceDateTimeRange,
+ WorkspaceDateTimeRange,
+ workspaceDateTimePresetWindow
+} from './WorkspaceDateTimeRange';
+
+test('normalizes Semi values and creates stable operational presets', () => {
+ const now = new Date(2026, 6, 19, 14, 30);
+ expect(normalizeWorkspaceDateTimeRange([], ['2026-07-19 08:15', '2026-07-19 14:30'])).toEqual({
+ dateFrom: '2026-07-19T08:15',
+ dateTo: '2026-07-19T14:30'
+ });
+ expect(workspaceDateTimePresetWindow('today', now)).toEqual({ dateFrom: '2026-07-19T00:00', dateTo: '2026-07-19T14:30' });
+ expect(workspaceDateTimePresetWindow('yesterday', now)).toEqual({ dateFrom: '2026-07-18T00:00', dateTo: '2026-07-18T23:59' });
+ expect(workspaceDateTimePresetWindow('last6Hours', now)).toEqual({ dateFrom: '2026-07-19T08:30', dateTo: '2026-07-19T14:30' });
+ expect(workspaceDateTimePresetWindow('last24Hours', now)).toEqual({ dateFrom: '2026-07-18T14:30', dateTo: '2026-07-19T14:30' });
+ expect(workspaceDateTimePresetWindow('last3Days', now)).toEqual({ dateFrom: '2026-07-17T00:00', dateTo: '2026-07-19T14:30' });
+});
+
+test('renders one Semi range with accessible quick actions', () => {
+ const onChange = vi.fn();
+ const today = { dateFrom: '2026-07-19T00:00', dateTo: '2026-07-19T14:30' };
+ const yesterday = { dateFrom: '2026-07-18T00:00', dateTo: '2026-07-18T23:59' };
+ const view = render();
+
+ expect(view.container.querySelector('.v2-workspace-date-time-picker.semi-datepicker')).toBeInTheDocument();
+ expect(screen.getByRole('group', { name: '时间范围快捷范围' })).toHaveClass('semi-button-group');
+ expect(screen.getByRole('button', { name: '今天' })).toHaveAttribute('aria-pressed', 'true');
+ expect(view.container.querySelector('input[type="datetime-local"]')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: '昨天' }));
+ expect(onChange).toHaveBeenCalledWith(yesterday);
+});
diff --git a/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDateTimeRange.tsx b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDateTimeRange.tsx
new file mode 100644
index 00000000..d81c80f6
--- /dev/null
+++ b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDateTimeRange.tsx
@@ -0,0 +1,139 @@
+import { Button, ButtonGroup, DatePicker } from '@douyinfe/semi-ui';
+
+export type WorkspaceDateTimeRangeValue = {
+ dateFrom: string;
+ dateTo: string;
+};
+
+export type WorkspaceDateTimePresetKey =
+ | 'today'
+ | 'yesterday'
+ | 'last6Hours'
+ | 'last24Hours'
+ | 'last3Days';
+
+export type WorkspaceDateTimePreset = {
+ key: string;
+ label: string;
+ range: WorkspaceDateTimeRangeValue;
+ active?: boolean;
+};
+
+function localMinute(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 workspaceDateTimePresetWindow(
+ preset: WorkspaceDateTimePresetKey,
+ now = new Date()
+): WorkspaceDateTimeRangeValue {
+ if (preset === 'yesterday') {
+ return {
+ dateFrom: localMinute(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0)),
+ dateTo: localMinute(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59))
+ };
+ }
+ if (preset === 'last6Hours' || preset === 'last24Hours') {
+ const hours = preset === 'last6Hours' ? 6 : 24;
+ return {
+ dateFrom: localMinute(new Date(now.getTime() - hours * 60 * 60_000)),
+ dateTo: localMinute(now)
+ };
+ }
+ if (preset === 'last3Days') {
+ return {
+ dateFrom: localMinute(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2, 0, 0)),
+ dateTo: localMinute(now)
+ };
+ }
+ return {
+ dateFrom: localMinute(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0)),
+ dateTo: localMinute(now)
+ };
+}
+
+export function normalizeWorkspaceDateTimeRange(
+ dateValue?: Date | Date[] | string | string[],
+ formattedValue?: string | string[] | Date | Date[]
+): WorkspaceDateTimeRangeValue | undefined {
+ 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 localMinute(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] };
+}
+
+export function WorkspaceDateTimeRange({
+ id,
+ label = '时间范围',
+ value,
+ onChange,
+ presets = [],
+ showClear = false,
+ className = '',
+ pickerClassName = '',
+ presetsClassName = '',
+ presetsAriaLabel
+}: {
+ id: string;
+ label?: string;
+ value: WorkspaceDateTimeRangeValue;
+ onChange: (value: WorkspaceDateTimeRangeValue) => void;
+ presets?: readonly WorkspaceDateTimePreset[];
+ showClear?: boolean;
+ className?: string;
+ pickerClassName?: string;
+ presetsClassName?: string;
+ presetsAriaLabel?: string;
+}) {
+ const labelId = `${id}-label`;
+ const classes = ['v2-workspace-date-time-range', className].filter(Boolean).join(' ');
+ const pickerClasses = ['v2-workspace-date-time-picker', pickerClassName].filter(Boolean).join(' ');
+ const presetClasses = ['v2-workspace-date-time-presets', presetsClassName].filter(Boolean).join(' ');
+ const pickerValue = value.dateFrom && value.dateTo
+ ? [new Date(value.dateFrom), new Date(value.dateTo)]
+ : [];
+
+ return ;
+}
diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
index 3453ea26..fd13b04a 100644
--- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
+++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
@@ -23880,3 +23880,164 @@
max-width: 300px;
}
}
+
+/*
+ * Shared Semi UI date-time range.
+ * History evidence, trajectory replay and alert handling now use one control
+ * family instead of browser-native date inputs with platform-specific chrome.
+ */
+.v2-workspace-date-time-range {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-content: end;
+ gap: 5px 8px;
+ margin: 0;
+ border: 0;
+ padding: 0;
+}
+
+.v2-workspace-date-time-range > legend {
+ grid-column: 1;
+ align-self: center;
+ margin: 0;
+ padding: 0;
+ color: #66778e;
+ font-size: 11px;
+ font-weight: 650;
+ line-height: 20px;
+}
+
+.v2-workspace-date-time-picker.semi-datepicker {
+ width: 100%;
+ min-width: 0;
+ min-height: 40px;
+ grid-column: 1 / -1;
+}
+
+.v2-workspace-date-time-picker .semi-datepicker-input {
+ width: 100%;
+}
+
+.v2-workspace-date-time-picker > .semi-datepicker-input {
+ grid-column: 1 / -1;
+}
+
+.v2-workspace-date-time-picker .semi-input-wrapper {
+ min-width: 0;
+ min-height: 40px;
+ border-color: #d8e2ed;
+ border-radius: 8px;
+ background: #fff;
+}
+
+.v2-workspace-date-time-picker .semi-input-wrapper:hover {
+ border-color: #b9c9dc;
+ background: #fff;
+}
+
+.v2-workspace-date-time-picker .semi-input-wrapper-focus {
+ border-color: #75a5ed;
+ background: #fff;
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, .09);
+}
+
+.v2-workspace-date-time-picker .semi-input {
+ min-width: 0;
+ color: #2d4058;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.v2-workspace-date-time-presets.semi-button-group {
+ display: flex;
+ height: 24px;
+ min-width: 0;
+ min-height: 24px;
+ grid-column: 2;
+ grid-row: 1;
+ justify-self: end;
+ gap: 3px;
+}
+
+.v2-workspace-date-time-presets > .semi-button {
+ height: 24px;
+ min-width: 0;
+ min-height: 24px;
+ flex: 0 0 auto;
+ border-radius: 6px;
+ padding-inline: 7px;
+ font-size: 10px;
+ font-weight: 650;
+}
+
+.v2-workspace-date-time-presets > .semi-button.semi-button-solid {
+ box-shadow: none;
+}
+
+.v2-track-query > .v2-track-date-time-range {
+ margin: 8px 0;
+}
+
+.v2-track-date-time-range > legend {
+ font-size: 10px;
+}
+
+.v2-track-date-time-picker .semi-input-wrapper {
+ min-height: 38px;
+ background: #fbfcfe;
+}
+
+.v2-track-date-time-picker .semi-input {
+ padding-inline: 3px;
+ font-size: 11px;
+}
+
+.v2-track-date-time-presets > .semi-button {
+ padding-inline: 6px;
+ font-size: 9px;
+}
+
+.v2-alert-advanced-filter > .v2-alert-date-time-range,
+.v2-alert-mobile-filter-grid > .v2-alert-date-time-range {
+ width: 100%;
+}
+
+.v2-alert-advanced-filter > .v2-alert-date-time-range {
+ margin-top: 2px;
+}
+
+.v2-alert-mobile-filter-grid > .v2-alert-date-time-range {
+ grid-column: 1 / -1;
+}
+
+@media (max-width: 680px) {
+ .v2-workspace-date-time-picker.semi-datepicker,
+ .v2-workspace-date-time-picker .semi-datepicker-input,
+ .v2-workspace-date-time-picker .semi-input-wrapper {
+ width: 100%;
+ min-height: 44px;
+ }
+
+ .v2-workspace-date-time-presets.semi-button-group {
+ max-width: 100%;
+ overflow-x: auto;
+ justify-self: end;
+ scrollbar-width: none;
+ }
+
+ .v2-workspace-date-time-presets.semi-button-group::-webkit-scrollbar {
+ display: none;
+ }
+
+ .v2-workspace-date-time-presets > .semi-button {
+ height: 26px;
+ min-height: 26px;
+ padding-inline: 8px;
+ }
+
+ .v2-alert-mobile-filter-grid .v2-alert-date-time-picker .semi-input {
+ padding-inline: 3px;
+ font-size: 12px;
+ }
+}