feat: unify mobile history filters
This commit is contained in:
@@ -194,6 +194,38 @@ test('clears stale rows, charts, and evidence as soon as the history scope chang
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('uses one focused Semi bottom SideSheet for the complete mobile history query', async () => {
|
||||
layout.mobile = true;
|
||||
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
|
||||
mocks.historyData.mockResolvedValue(historyData('MOBILEVIN', '粤A移动历史', 'mobile-as-of'));
|
||||
mocks.historySeries.mockResolvedValue(historySeries('MOBILEVIN', '粤A移动历史', 'mobile-as-of'));
|
||||
mocks.historyExports.mockResolvedValue([]);
|
||||
renderPage('/history');
|
||||
|
||||
await screen.findByText('等待查询历史数据');
|
||||
const trigger = screen.getByRole('button', { name: '修改查询范围:请选择车辆' });
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '历史查询范围' });
|
||||
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).getByRole('combobox', { name: '数据类型' })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('combobox', { name: '数据来源' })).toBeInTheDocument();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('车牌 / VIN,多台用逗号分隔'), { target: { value: 'MOBILEVIN' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '应用并查询' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = mocks.historyData.mock.calls;
|
||||
expect((calls[calls.length - 1]?.[0] as URLSearchParams | undefined)?.get('keywords')).toBe('MOBILEVIN');
|
||||
});
|
||||
expect(screen.getByRole('button', { name: '修改查询范围:1 辆 · 位置数据' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByRole('dialog', { name: '历史查询范围' })).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
|
||||
});
|
||||
|
||||
test('renders only selectable Semi history cards on mobile', async () => {
|
||||
layout.mobile = true;
|
||||
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
|
||||
|
||||
@@ -10,6 +10,7 @@ import { downloadBlob } from '../domain/download';
|
||||
import { formatShanghaiDateTime } from '../domain/formatters';
|
||||
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { PlatformTime } from '../shared/PlatformTime';
|
||||
import { ProtocolTag } from '../shared/ProtocolTag';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
@@ -339,11 +340,12 @@ export default function HistoryPage() {
|
||||
const closeSelectedRow = useCallback(() => {
|
||||
setSelectedRowRef({ scope: dataScope, id: '' });
|
||||
}, [dataScope]);
|
||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||
useSideSheetA11y(mobileLayout && Boolean(selectedRow), '.v2-history-detail-sidesheet', 'v2-history-detail', '历史数据详情', '关闭历史数据详情');
|
||||
useSideSheetA11y(exportAllowed && exportJobsOpen, '.v2-history-export-sidesheet', 'v2-history-export-jobs', '历史数据导出任务', '关闭历史数据导出任务');
|
||||
useSideSheetA11y(mobileFiltersOpen, '.v2-history-filter-sidesheet', 'v2-history-filter-sheet', '历史查询范围', '关闭历史查询范围');
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const applyDraft = () => {
|
||||
const parsed = parseHistoryKeywords(draft.keywords);
|
||||
if (!parsed.length) return;
|
||||
const next = { ...draft, keywords: parsed.join(',') };
|
||||
@@ -355,7 +357,8 @@ export default function HistoryPage() {
|
||||
if (next.protocol) url.set('protocol', next.protocol);
|
||||
setSearchParams(preserveMonitorReturn(url, monitorReturn), { replace: true });
|
||||
};
|
||||
const reset = () => { const next = { keywords: '', ...currentHistoryWindow(), category: 'location', protocol: '' }; setDraft(next); setCriteria(next); setOffset(0); setSearchParams(preserveMonitorReturn(new URLSearchParams(), monitorReturn), { replace: true }); };
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); applyDraft(); };
|
||||
const reset = () => { const next = { keywords: '', ...currentHistoryWindow(), category: 'location', protocol: '' }; setDraft(next); setCriteria(next); setOffset(0); setFiltersCollapsed(true); setSearchParams(preserveMonitorReturn(new URLSearchParams(), monitorReturn), { replace: true }); };
|
||||
const toggleMetric = (key: string) => setVisibleByCategory((current) => {
|
||||
const baseline = current[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);
|
||||
const next = baseline.includes(key) ? baseline.filter((item) => item !== key) : [...baseline, key];
|
||||
@@ -398,6 +401,13 @@ export default function HistoryPage() {
|
||||
className: 'is-duration'
|
||||
}
|
||||
];
|
||||
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>
|
||||
<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>
|
||||
</>;
|
||||
const exportRequest: HistoryExportRequest = { keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' };
|
||||
|
||||
return <div className="v2-history-page">
|
||||
<MonitorReturnBar />
|
||||
@@ -411,7 +421,34 @@ export default function HistoryPage() {
|
||||
statusColor={keywords.length ? 'blue' : 'grey'}
|
||||
meta={<Typography.Text type="tertiary">上海时区 · 最多 5 台车辆</Typography.Text>}
|
||||
/>
|
||||
<WorkspaceFilterPanel
|
||||
{mobileLayout ? <div className="v2-history-mobile-discovery">
|
||||
<MobileFilterToggle
|
||||
title="查询范围"
|
||||
summary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}
|
||||
expanded={mobileFiltersOpen}
|
||||
expandedLabel="关闭"
|
||||
collapsedLabel="修改"
|
||||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||||
/>
|
||||
<SideSheet
|
||||
className="v2-history-filter-sidesheet"
|
||||
visible={mobileFiltersOpen}
|
||||
placement="bottom"
|
||||
height="min(82dvh, 700px)"
|
||||
aria-label="历史查询范围"
|
||||
title={<div className="v2-history-mobile-sheet-title"><strong>筛选历史数据</strong><span>车辆、时间、数据类型与协议来源</span></div>}
|
||||
onCancel={() => setFiltersCollapsed(true)}
|
||||
footer={<div className="v2-history-mobile-filter-footer"><Button theme="light" onClick={reset}>重置条件</Button><Button theme="solid" disabled={!parseHistoryKeywords(draft.keywords).length} onClick={applyDraft}>应用并查询</Button></div>}
|
||||
>
|
||||
<form className="v2-history-mobile-filter-form" onSubmit={submit}>
|
||||
<section className="v2-history-mobile-filter-section" aria-label="历史查询条件">
|
||||
<header><strong>查询范围</strong><span>最多 5 台车辆,按上海时区检索可追溯数据</span></header>
|
||||
<div className="v2-history-mobile-filter-grid">{historyFilterFields}</div>
|
||||
</section>
|
||||
{exportAllowed ? <section className="v2-history-mobile-export-action" aria-label="导出当前历史查询"><span><strong>导出当前结果</strong><small>按已应用范围与当前字段生成 CSV</small></span><CreateExportButton disabled={!resultRows.length} request={exportRequest} /></section> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large">只读</Tag>}
|
||||
</form>
|
||||
</SideSheet>
|
||||
</div> : <WorkspaceFilterPanel
|
||||
className="v2-history-filter-card"
|
||||
title="查询范围"
|
||||
description="车辆、时间、数据类型与协议来源"
|
||||
@@ -422,13 +459,10 @@ export default function HistoryPage() {
|
||||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||||
>
|
||||
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
|
||||
<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>
|
||||
<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>
|
||||
<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={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large">只读</Tag>}</div>
|
||||
{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>
|
||||
</form>
|
||||
</WorkspaceFilterPanel>
|
||||
</WorkspaceFilterPanel>}
|
||||
</div>
|
||||
{keywords.length ? <WorkspaceMetricRail
|
||||
className="v2-history-summary-rail"
|
||||
|
||||
@@ -17039,3 +17039,199 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Semi UI history mobile workflow.
|
||||
* Preserve the data list as the primary workspace while the complete vehicle,
|
||||
* time and source query runs in a focused, scroll-safe bottom sheet.
|
||||
*/
|
||||
@media (max-width: 680px) {
|
||||
.v2-history-mobile-discovery {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-discovery > .v2-mobile-filter-toggle.semi-button {
|
||||
min-height: 58px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.v2-history-filter-sidesheet .semi-sidesheet-inner {
|
||||
width: 100vw !important;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 18px 18px 0 0;
|
||||
background: #f4f7fb;
|
||||
box-shadow: 0 -20px 56px rgba(25, 45, 72, .2);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.v2-history-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header {
|
||||
min-height: 68px;
|
||||
border-bottom: 1px solid #dfe7f0;
|
||||
background: rgba(255, 255, 255, .98);
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
.v2-history-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background: #f4f7fb;
|
||||
padding: 12px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.v2-history-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-footer {
|
||||
border-top: 1px solid #dfe7f0;
|
||||
background: rgba(255, 255, 255, .98);
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border: 1px solid #dfe7f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
box-shadow: 0 8px 24px rgba(31, 53, 80, .055);
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-section > header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-section > header > strong {
|
||||
color: #2b4058;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-section > header > span {
|
||||
color: #7c8b9e;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 11px 9px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-grid > .v2-history-vehicles,
|
||||
.v2-history-mobile-filter-grid > .v2-history-range {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-grid > label,
|
||||
.v2-history-mobile-filter-grid > fieldset {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: #5f7188;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-grid > fieldset > legend {
|
||||
margin-bottom: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-grid > label > .semi-input-wrapper,
|
||||
.v2-history-mobile-filter-grid > label > .semi-select {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-grid .v2-history-range > div {
|
||||
display: grid;
|
||||
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 {
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-grid .v2-history-range > div > span {
|
||||
color: #8a98aa;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-grid .semi-input,
|
||||
.v2-history-mobile-filter-grid .semi-select-selection-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-export-action {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border: 1px solid #dfe7f0;
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
padding: 10px 11px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-export-action > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-export-action strong {
|
||||
color: #40536b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-export-action small {
|
||||
color: #8794a6;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.v2-history-mobile-export-action > .semi-button {
|
||||
min-height: 38px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-footer {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr);
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.v2-history-mobile-filter-footer > .semi-button {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-radius: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user