refactor: unify semi operational time ranges

This commit is contained in:
lingniu
2026-07-19 15:11:15 +08:00
parent 476069adc6
commit fb105a67c8
8 changed files with 407 additions and 66 deletions

View File

@@ -298,6 +298,10 @@ test('keeps the primary alert query compact and applies advanced filters from a
fireEvent.click(screen.getByRole('button', { name: /更多筛选/ })); fireEvent.click(screen.getByRole('button', { name: /更多筛选/ }));
const dialog = await screen.findByRole('dialog', { name: '告警高级筛选' }); const dialog = await screen.findByRole('dialog', { name: '告警高级筛选' });
expect(within(dialog).getByRole('group', { name: '告警快捷时间范围' })).toHaveClass('semi-button-group');
expect(within(dialog).getByPlaceholderText('开始时间')).toBeInTheDocument();
expect(within(dialog).getByPlaceholderText('结束时间')).toBeInTheDocument();
expect(within(dialog).queryByRole('textbox', { name: '告警起始时间' })).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('combobox', { name: '协议' })); fireEvent.click(within(dialog).getByRole('combobox', { name: '协议' }));
fireEvent.click(await screen.findByText('JT808')); fireEvent.click(await screen.findByText('JT808'));
fireEvent.click(within(dialog).getByRole('button', { name: '应用筛选' })); fireEvent.click(within(dialog).getByRole('button', { name: '应用筛选' }));
@@ -325,8 +329,10 @@ test('uses one focused Semi bottom SideSheet for the complete mobile event filte
expect(document.querySelector('.v2-alert-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 690px)' }); expect(document.querySelector('.v2-alert-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 690px)' });
expect(within(dialog).getByRole('textbox', { name: /关键词/ })).toBeInTheDocument(); expect(within(dialog).getByRole('textbox', { name: /关键词/ })).toBeInTheDocument();
expect(within(dialog).getByRole('combobox', { name: '协议' })).toBeInTheDocument(); expect(within(dialog).getByRole('combobox', { name: '协议' })).toBeInTheDocument();
expect(within(dialog).getByLabelText('告警起始时间')).toBeInTheDocument(); expect(within(dialog).getByPlaceholderText('始时间')).toBeInTheDocument();
expect(within(dialog).getByLabelText('告警结束时间')).toBeInTheDocument(); expect(within(dialog).getByPlaceholderText('结束时间')).toBeInTheDocument();
expect(within(dialog).getByRole('group', { name: '告警快捷时间范围' })).toHaveClass('semi-button-group');
expect(within(dialog).queryByRole('textbox', { name: '告警起始时间' })).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '重置条件' })); fireEvent.click(within(dialog).getByRole('button', { name: '重置条件' }));
expect(screen.getByRole('dialog', { name: '告警事件筛选' })).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: '告警事件筛选' })).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('combobox', { name: '协议' })); fireEvent.click(within(dialog).getByRole('combobox', { name: '协议' }));

View File

@@ -13,6 +13,7 @@ import { ProtocolTag } from '../shared/ProtocolTag';
import { SegmentedTabs } from '../shared/SegmentedTabs'; import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination'; import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceDateTimeRange, workspaceDateTimePresetWindow } from '../shared/WorkspaceDateTimeRange';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet'; import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
@@ -195,6 +196,11 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
const selectEvent = useCallback((id: string) => setSelection({ scope: eventScope, id }), [eventScope]); const selectEvent = useCallback((id: string) => setSelection({ scope: eventScope, id }), [eventScope]);
const selectedEvent = detail.data ?? rows.find((row) => row.id === selectedID); const selectedEvent = detail.data ?? rows.find((row) => row.id === selectedID);
const mobileFiltersOpen = mobileLayout && !filtersCollapsed; const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
const alertTimePresets = useMemo(() => [
{ key: 'today', label: '今天', range: workspaceDateTimePresetWindow('today') },
{ key: 'yesterday', label: '昨天', range: workspaceDateTimePresetWindow('yesterday') },
{ key: 'last24Hours', label: '近 24 小时', range: workspaceDateTimePresetWindow('last24Hours') }
], []);
const applyDraft = () => { const applyDraft = () => {
setFilters(draft); setFilters(draft);
setOffset(0); setOffset(0);
@@ -226,8 +232,18 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
const advancedFilterFields = <> const advancedFilterFields = <>
<label><span id="alert-rule-label"></span><Select aria-labelledby="alert-rule-label" value={draft.ruleId} onChange={(value) => setDraft({ ...draft, ruleId: String(value) })} optionList={[{ value: '', label: '全部规则' }, ...rules.map((rule) => ({ value: rule.id, label: rule.name }))]} /></label> <label><span id="alert-rule-label"></span><Select aria-labelledby="alert-rule-label" value={draft.ruleId} onChange={(value) => setDraft({ ...draft, ruleId: String(value) })} optionList={[{ value: '', label: '全部规则' }, ...rules.map((rule) => ({ value: rule.id, label: rule.name }))]} /></label>
<label><span id="alert-protocol-label"></span><Select aria-labelledby="alert-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label> <label><span id="alert-protocol-label"></span><Select aria-labelledby="alert-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label>
<label><span></span><Input aria-label="告警起始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft({ ...draft, dateFrom: value })} /></label> <WorkspaceDateTimeRange
<label><span></span><Input aria-label="告警结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft({ ...draft, dateTo: value })} /></label> id="v2-alert-date-time"
label="触发时间"
className="v2-alert-date-time-range"
pickerClassName="v2-alert-date-time-picker"
presetsClassName="v2-alert-date-time-presets"
presetsAriaLabel="告警快捷时间范围"
value={draft}
onChange={(range) => setDraft({ ...draft, ...range })}
presets={alertTimePresets}
showClear
/>
</>; </>;
const metricItem = ( const metricItem = (
label: string, label: string,

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
import { IconCalendar, IconChevronRight, IconClose, IconDownload, IconMapPin, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { IconCalendar, IconChevronRight, IconClose, IconDownload, IconMapPin, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, ButtonGroup, Card, CardGroup, Checkbox, DatePicker, Descriptions, Dropdown, Empty, Input, Progress, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Empty, Input, Progress, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { FormEvent, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client'; import { api } from '../../api/client';
@@ -16,6 +16,11 @@ import { PlatformTime } from '../shared/PlatformTime';
import { ProtocolTag } from '../shared/ProtocolTag'; import { ProtocolTag } from '../shared/ProtocolTag';
import { TablePagination } from '../shared/TablePagination'; import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import {
normalizeWorkspaceDateTimeRange,
WorkspaceDateTimeRange,
workspaceDateTimePresetWindow
} from '../shared/WorkspaceDateTimeRange';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet'; import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail'; import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
@@ -117,40 +122,15 @@ function currentHistoryWindow() {
type HistoryRangePreset = 'today' | 'yesterday' | 'last6Hours' | 'last24Hours'; 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()) { export function historyPresetWindow(preset: HistoryRangePreset, now = new Date()) {
if (preset === 'yesterday') { return workspaceDateTimePresetWindow(preset, now);
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( export function historyDateTimePickerRange(
dateValue?: Date | Date[] | string | string[], dateValue?: Date | Date[] | string | string[],
formattedValue?: string | string[] | Date | Date[] formattedValue?: string | string[] | Date | Date[]
) { ) {
const values = Array.isArray(formattedValue) && formattedValue.length === 2 return normalizeWorkspaceDateTimeRange(dateValue, formattedValue);
? 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 }) { function CreateExportButton({ request, disabled }: { request: HistoryExportRequest; disabled: boolean }) {
@@ -518,32 +498,16 @@ export default function HistoryPage() {
]; ];
const historyFilterFields = <> const historyFilterFields = <>
<label className="v2-history-vehicles"><span> 5 </span><Input prefix={<IconSearch />} value={draft.keywords} onChange={(value) => setDraft((current) => ({ ...current, keywords: value }))} placeholder="车牌 / VIN多台用逗号分隔" /></label> <label className="v2-history-vehicles"><span> 5 </span><Input prefix={<IconSearch />} value={draft.keywords} onChange={(value) => setDraft((current) => ({ ...current, keywords: value }))} placeholder="车牌 / VIN多台用逗号分隔" /></label>
<fieldset className="v2-history-range"> <WorkspaceDateTimeRange
<legend id="v2-history-date-time-label"></legend> id="v2-history-date-time"
<DatePicker className="v2-history-range"
className="v2-history-date-time-range" pickerClassName="v2-history-date-time-range"
type="dateTimeRange" presetsClassName="v2-history-range-presets"
density="compact" presetsAriaLabel="快捷时间范围"
format="yyyy-MM-dd HH:mm" value={draft}
value={[new Date(draft.dateFrom), new Date(draft.dateTo)]} onChange={(range) => setDraft((current) => ({ ...current, ...range }))}
placeholder={['开始时间', '结束时间']} presets={historyRangePresets}
showClear={false}
onChangeWithDateFirst
aria-labelledby="v2-history-date-time-label"
onChange={(value, formattedValue) => {
const range = historyDateTimePickerRange(value, formattedValue);
if (range) setDraft((current) => ({ ...current, ...range }));
}}
/> />
<ButtonGroup className="v2-history-range-presets" aria-label="快捷时间范围">
{historyRangePresets.map((preset) => <Button
key={preset.key}
htmlType="button"
theme={draft.dateFrom === preset.range.dateFrom && draft.dateTo === preset.range.dateTo ? 'solid' : 'light'}
onClick={() => setDraft((current) => ({ ...current, ...preset.range }))}
>{preset.label}</Button>)}
</ButtonGroup>
</fieldset>
<label><span id="history-category-label"></span><Select aria-labelledby="history-category-label" value={draft.category} onChange={(value) => setDraft((current) => ({ ...current, category: String(value) }))} optionList={(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => ({ value: item.key, label: item.label }))} /></label> <label><span id="history-category-label"></span><Select aria-labelledby="history-category-label" value={draft.category} onChange={(value) => setDraft((current) => ({ ...current, category: String(value) }))} optionList={(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => ({ value: item.key, label: item.label }))} /></label>
<label><span id="history-protocol-label"></span><Select aria-labelledby="history-protocol-label" value={draft.protocol} onChange={(value) => setDraft((current) => ({ ...current, protocol: String(value) }))} optionList={[{ value: '', label: '全部来源' }, { value: 'GB32960', label: 'GB32960' }, { value: 'JT808', label: 'JT808' }, { value: 'YUTONG_MQTT', label: 'YUTONG_MQTT' }]} /></label> <label><span id="history-protocol-label"></span><Select aria-labelledby="history-protocol-label" value={draft.protocol} onChange={(value) => setDraft((current) => ({ ...current, protocol: String(value) }))} optionList={[{ value: '', label: '全部来源' }, { value: 'GB32960', label: 'GB32960' }, { value: 'JT808', label: 'JT808' }, { value: 'YUTONG_MQTT', label: 'YUTONG_MQTT' }]} /></label>
</>; </>;

View File

@@ -154,6 +154,9 @@ test('merges duplicate vehicle sources and keeps the active date shortcut visibl
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks']}><TrackPage /></MemoryRouter></QueryClientProvider>); render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks']}><TrackPage /></MemoryRouter></QueryClientProvider>);
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'); expect(screen.getByRole('button', { name: '今天' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: '昨天' })); fireEvent.click(screen.getByRole('button', { name: '昨天' }));
expect(screen.getByRole('button', { name: '昨天' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: '昨天' })).toHaveAttribute('aria-pressed', 'true');

View File

@@ -18,6 +18,7 @@ import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates'; import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceDateTimeRange, workspaceDateTimePresetWindow } from '../shared/WorkspaceDateTimeRange';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail'; import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
@@ -249,7 +250,11 @@ const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading,
onCollapse: () => void; onCollapse: () => void;
}) { }) {
const activePreset = trackPreset(draft); 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 appliedWindow = `${draft.dateFrom.replace('T', ' ').slice(5)} ${draft.dateTo.replace('T', ' ').slice(5)}`;
const appliedSource = draft.protocol ? protocolDisplayLabel(draft.protocol) : '自动来源'; const appliedSource = draft.protocol ? protocolDisplayLabel(draft.protocol) : '自动来源';
const contextTitle = queryCollapsed ? track?.plate || draft.keyword || '轨迹查询' : '轨迹查询'; const contextTitle = queryCollapsed ? track?.plate || draft.keyword || '轨迹查询' : '轨迹查询';
@@ -279,12 +284,16 @@ const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading,
/> : null} /> : null}
{!queryCollapsed ? <form className="v2-track-query" onSubmit={onSubmit}> {!queryCollapsed ? <form className="v2-track-query" onSubmit={onSubmit}>
<label><span></span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label> <label><span></span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label>
<div className="v2-track-presets"> <WorkspaceDateTimeRange
<Button theme={activePreset === 'today' ? 'solid' : 'light'} aria-pressed={activePreset === 'today'} onClick={() => choosePreset(0)}></Button> id="v2-track-date-time"
<Button theme={activePreset === 'yesterday' ? 'solid' : 'light'} aria-pressed={activePreset === 'yesterday'} onClick={() => choosePreset(1)}></Button> className="v2-track-date-time-range"
<Button theme={activePreset === 'three-days' ? 'solid' : 'light'} aria-pressed={activePreset === 'three-days'} onClick={() => { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 2); start.setHours(0, 0, 0, 0); onDraft({ ...draft, dateFrom: localDateTime(start), dateTo: localDateTime(end) }); }}> 3 </Button> pickerClassName="v2-track-date-time-picker"
</div> presetsClassName="v2-track-date-time-presets"
<div className="v2-track-date-grid"><label><span></span><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => onDraft({ ...draft, dateFrom: value })} /></label><label><span></span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => onDraft({ ...draft, dateTo: value })} /></label></div> presetsAriaLabel="轨迹快捷时间范围"
value={draft}
onChange={(range) => onDraft({ ...draft, ...range })}
presets={trackRangePresets}
/>
<label><span id="track-source-label"></span><Select aria-labelledby="track-source-label" value={draft.protocol} onChange={(value) => onDraft({ ...draft, protocol: String(value) })} optionList={TRACK_SOURCE_OPTIONS} /></label> <label><span id="track-source-label"></span><Select aria-labelledby="track-source-label" value={draft.protocol} onChange={(value) => onDraft({ ...draft, protocol: String(value) })} optionList={TRACK_SOURCE_OPTIONS} /></label>
<Button className="v2-track-query-button" theme="solid" htmlType="submit" icon={<IconSearch />} loading={loading} disabled={!draft.keyword.trim()}></Button> <Button className="v2-track-query-button" theme="solid" htmlType="submit" icon={<IconSearch />} loading={loading} disabled={!draft.keyword.trim()}></Button>
</form> : null} </form> : null}

View File

@@ -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(<WorkspaceDateTimeRange
id="qa-range"
value={today}
onChange={onChange}
presets={[
{ key: 'today', label: '今天', range: today },
{ key: 'yesterday', label: '昨天', range: yesterday }
]}
/>);
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);
});

View File

@@ -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 <fieldset className={classes}>
<legend id={labelId}>{label}</legend>
<DatePicker
className={pickerClasses}
type="dateTimeRange"
density="compact"
format="yyyy-MM-dd HH:mm"
value={pickerValue}
placeholder={['开始时间', '结束时间']}
showClear={showClear}
onChangeWithDateFirst
aria-labelledby={labelId}
onChange={(dateValue, formattedValue) => {
const next = normalizeWorkspaceDateTimeRange(dateValue, formattedValue);
if (next) onChange(next);
else if (showClear && (!dateValue || (Array.isArray(dateValue) && !dateValue.length))) {
onChange({ dateFrom: '', dateTo: '' });
}
}}
/>
{presets.length ? <ButtonGroup className={presetClasses} aria-label={presetsAriaLabel || `${label}快捷范围`}>
{presets.map((preset) => {
const active = preset.active ?? (
value.dateFrom === preset.range.dateFrom
&& value.dateTo === preset.range.dateTo
);
return <Button
key={preset.key}
htmlType="button"
theme={active ? 'solid' : 'light'}
aria-pressed={active}
onClick={() => onChange(preset.range)}
>{preset.label}</Button>;
})}
</ButtonGroup> : null}
</fieldset>;
}

View File

@@ -23880,3 +23880,164 @@
max-width: 300px; 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;
}
}