refactor: unify semi history date filters

This commit is contained in:
lingniu
2026-07-19 14:40:57 +08:00
parent 2d987fd185
commit 476069adc6
3 changed files with 233 additions and 15 deletions

View File

@@ -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: '重置条件' }));

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, 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 = <>
<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></legend><div><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /><span></span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></div></fieldset>
<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>
<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>
</>;
@@ -536,7 +603,7 @@ export default function HistoryPage() {
>
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
{historyFilterFields}
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></Button><Button className="v2-secondary-button" theme="light" onClick={reset}></Button>{exportAllowed ? <CreateExportButton disabled={!resultRows.length} request={exportRequest} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag>}</div>
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></Button><Button className="v2-secondary-button" theme="light" onClick={reset}></Button>{!exportAllowed ? <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag> : null}</div>
</form>
</WorkspaceFilterPanel>}
</div>
@@ -575,6 +642,7 @@ export default function HistoryPage() {
</> : null : <>
<Button className={`v2-history-table-trend${trendExpanded ? ' is-expanded' : ''}`} theme="borderless" aria-label={trendExpanded ? '收起趋势' : '展开趋势'} aria-expanded={trendExpanded} icon={<IconChevronRight />} onClick={() => setTrendExpanded((value) => !value)}>{trendExpanded ? '收起趋势' : '聚合趋势'}</Button>
<Button theme="borderless" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} aria-controls="v2-history-column-settings" icon={<IconSetting />} onClick={() => setColumnSettingsOpen((value) => !value)}></Button>
{exportAllowed ? <CreateExportButton disabled={!resultRows.length} request={exportRequest} /> : null}
{exportAllowed ? <Button theme="borderless" aria-label="查看导出任务" aria-haspopup="dialog" aria-expanded={exportJobsOpen} aria-controls="v2-history-export-jobs" icon={<IconDownload />} onClick={() => setExportJobsOpen(true)}></Button> : null}
<Select aria-label="表格密度" value={density} onChange={(value) => setDensity(String(value) as typeof density)} optionList={[{ value: 'compact', label: '紧凑' }, { value: 'comfortable', label: '舒适' }]} />
<Button theme="borderless" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据" icon={<IconRefresh />} />

View File

@@ -15367,6 +15367,102 @@
background: #fbfcfe;
}
.v2-history-range {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
align-content: end;
gap: 4px 8px;
margin: 0;
border: 0;
padding: 0;
}
.v2-history-range > legend {
grid-column: 1;
align-self: center;
margin: 0;
padding: 0;
line-height: 18px;
}
.v2-history-date-time-range.semi-datepicker {
width: 100%;
min-width: 0;
min-height: 38px;
grid-column: 1 / -1;
}
.v2-history-date-time-range .semi-datepicker-input {
width: 100%;
}
.v2-history-date-time-range > .semi-datepicker-input {
grid-column: 1 / -1;
}
.v2-history-date-time-range .semi-input-wrapper {
min-height: 38px;
border-color: #d8e2ed;
border-radius: 8px;
background: #fbfcfe;
}
.v2-history-date-time-range .semi-input-wrapper:hover {
border-color: #b9c9dc;
background: #fff;
}
.v2-history-date-time-range .semi-input-wrapper-focus {
border-color: #75a5ed;
background: #fff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, .09);
}
.v2-history-date-time-range .semi-input {
color: #2d4058;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.v2-history-range > .v2-history-range-presets.semi-button-group {
display: flex;
height: 22px;
min-width: 0;
min-height: 22px;
grid-column: 2;
grid-row: 1;
justify-self: end;
gap: 2px;
}
.v2-history-range-presets > .semi-button {
height: 22px;
min-width: 0;
min-height: 22px;
border-radius: 5px;
padding: 0 6px;
font-size: 9px;
font-weight: 650;
}
.v2-history-range-presets > .semi-button.semi-button-solid {
box-shadow: none;
}
@media (min-width: 1101px) {
.v2-history-discovery-shell .v2-history-toolbar {
grid-template-columns: minmax(220px, 1.05fr) minmax(400px, 1.85fr) minmax(112px, .55fr) minmax(126px, .6fr) auto;
gap: 9px;
}
.v2-history-discovery-shell .v2-history-toolbar-actions {
display: grid;
grid-template-columns: repeat(2, minmax(54px, auto));
gap: 6px;
}
}
.v2-history-time {
color: inherit;
font-variant-numeric: tabular-nums;
@@ -15613,6 +15709,39 @@
background: #fff;
}
.v2-history-mobile-filter-grid > .v2-history-range {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px 8px;
}
.v2-history-date-time-range.semi-datepicker,
.v2-history-date-time-range .semi-datepicker-input,
.v2-history-date-time-range .semi-input-wrapper {
width: 100%;
min-height: 44px;
}
.v2-history-range > .v2-history-range-presets.semi-button-group {
height: 24px;
max-width: 100%;
overflow-x: auto;
justify-self: end;
scrollbar-width: none;
}
.v2-history-range > .v2-history-range-presets.semi-button-group::-webkit-scrollbar {
display: none;
}
.v2-history-range-presets > .semi-button {
height: 24px;
min-height: 24px;
flex: 0 0 auto;
padding-inline: 7px;
font-size: 9px;
}
.v2-history-mobile-card-content .v2-history-time {
color: #617288;
font-size: 10px;
@@ -18762,12 +18891,9 @@
background: #fff;
}
.v2-history-mobile-filter-grid .v2-history-range > div {
display: grid;
.v2-history-mobile-filter-grid .v2-history-date-time-range {
display: block;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 6px;
}
.v2-history-mobile-filter-grid .v2-history-range .semi-input-wrapper {
@@ -18777,11 +18903,17 @@
background: #fff;
}
.v2-history-mobile-filter-grid .v2-history-range > div > span {
.v2-history-mobile-filter-grid .v2-history-date-time-range > span {
color: #8a98aa;
font-size: 10px;
}
.v2-history-mobile-filter-grid .v2-history-date-time-range .semi-input {
padding-inline: 3px;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.v2-history-mobile-filter-grid .semi-input,
.v2-history-mobile-filter-grid .semi-select-selection-text {
font-size: 13px;