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

@@ -295,9 +295,13 @@ test('keeps the primary alert query compact and applies advanced filters from a
await screen.findByText('当前筛选条件没有告警事件');
expect(view.container.querySelector('.v2-alert-filter-primary')).toBeInTheDocument();
expect(screen.queryByLabelText('协议')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /更多筛选/ }));
fireEvent.click(screen.getByRole('button', { 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(await screen.findByText('JT808'));
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(within(dialog).getByRole('textbox', { name: /关键词/ })).toBeInTheDocument();
expect(within(dialog).getByRole('combobox', { name: '协议' })).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).queryByRole('textbox', { name: '告警起始时间' })).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '重置条件' }));
expect(screen.getByRole('dialog', { name: '告警事件筛选' })).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('combobox', { name: '协议' }));

View File

@@ -13,6 +13,7 @@ import { ProtocolTag } from '../shared/ProtocolTag';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceDateTimeRange, workspaceDateTimePresetWindow } from '../shared/WorkspaceDateTimeRange';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
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 selectedEvent = detail.data ?? rows.find((row) => row.id === selectedID);
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 = () => {
setFilters(draft);
setOffset(0);
@@ -226,8 +232,18 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
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-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>
<label><span></span><Input aria-label="告警结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft({ ...draft, dateTo: value })} /></label>
<WorkspaceDateTimeRange
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 = (
label: string,

View File

@@ -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, 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 { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
@@ -16,6 +16,11 @@ import { PlatformTime } from '../shared/PlatformTime';
import { ProtocolTag } from '../shared/ProtocolTag';
import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import {
normalizeWorkspaceDateTimeRange,
WorkspaceDateTimeRange,
workspaceDateTimePresetWindow
} from '../shared/WorkspaceDateTimeRange';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
@@ -117,40 +122,15 @@ function currentHistoryWindow() {
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) };
return workspaceDateTimePresetWindow(preset, 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] };
return normalizeWorkspaceDateTimeRange(dateValue, formattedValue);
}
function CreateExportButton({ request, disabled }: { request: HistoryExportRequest; disabled: boolean }) {
@@ -518,32 +498,16 @@ export default function HistoryPage() {
];
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>
<fieldset className="v2-history-range">
<legend id="v2-history-date-time-label"></legend>
<DatePicker
className="v2-history-date-time-range"
type="dateTimeRange"
density="compact"
format="yyyy-MM-dd HH:mm"
value={[new Date(draft.dateFrom), new Date(draft.dateTo)]}
placeholder={['开始时间', '结束时间']}
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>
<WorkspaceDateTimeRange
id="v2-history-date-time"
className="v2-history-range"
pickerClassName="v2-history-date-time-range"
presetsClassName="v2-history-range-presets"
presetsAriaLabel="快捷时间范围"
value={draft}
onChange={(range) => setDraft((current) => ({ ...current, ...range }))}
presets={historyRangePresets}
/>
<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>
</>;

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 } } });
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');
fireEvent.click(screen.getByRole('button', { name: '昨天' }));
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 { 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 ? <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>
<div className="v2-track-presets">
<Button theme={activePreset === 'today' ? 'solid' : 'light'} aria-pressed={activePreset === 'today'} onClick={() => choosePreset(0)}></Button>
<Button theme={activePreset === 'yesterday' ? 'solid' : 'light'} aria-pressed={activePreset === 'yesterday'} onClick={() => choosePreset(1)}></Button>
<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>
</div>
<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>
<WorkspaceDateTimeRange
id="v2-track-date-time"
className="v2-track-date-time-range"
pickerClassName="v2-track-date-time-picker"
presetsClassName="v2-track-date-time-presets"
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>
<Button className="v2-track-query-button" theme="solid" htmlType="submit" icon={<IconSearch />} loading={loading} disabled={!draft.keyword.trim()}></Button>
</form> : null}