feat(web): unify vehicle source navigation

This commit is contained in:
lingniu
2026-07-20 05:26:08 +08:00
parent c150e45f11
commit 843690cc71
6 changed files with 52 additions and 19 deletions

View File

@@ -100,6 +100,8 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
expect(screen.getByRole('heading', { name: '实时遥测', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '实时遥测', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '车辆主档', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '车辆主档', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: '单车详情导航' })).toBeInTheDocument(); expect(screen.getByRole('navigation', { name: '单车详情导航' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-record-page > .v2-source-evidence')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '来源证据' })).toBeInTheDocument();
const telemetryPanel = view.container.querySelector<HTMLElement>('#vehicle-telemetry-panel')!; const telemetryPanel = view.container.querySelector<HTMLElement>('#vehicle-telemetry-panel')!;
const scrollIntoView = vi.fn(); const scrollIntoView = vi.fn();
Object.defineProperty(telemetryPanel, 'scrollIntoView', { configurable: true, value: scrollIntoView }); Object.defineProperty(telemetryPanel, 'scrollIntoView', { configurable: true, value: scrollIntoView });
@@ -116,7 +118,7 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
expect(totalMileageSource).toHaveClass('semi-button', 'v2-workspace-metric-action'); expect(totalMileageSource).toHaveClass('semi-button', 'v2-workspace-metric-action');
expect(totalMileageSource.closest('.semi-card')).toHaveClass('v2-vehicle-live-metric-rail'); expect(totalMileageSource.closest('.semi-card')).toHaveClass('v2-vehicle-live-metric-rail');
expect(totalMileageSource.closest('.v2-live-overview')).toBe(commandCard); expect(totalMileageSource.closest('.v2-live-overview')).toBe(commandCard);
fireEvent.click(locationSource); fireEvent.click(screen.getByRole('button', { name: '来源证据' }));
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1)); await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
}); });
@@ -278,7 +280,7 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi
expect(view.container.querySelector('.v2-vehicle-directory-table')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-vehicle-directory-table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-query-panel')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-vehicle-query-panel')).not.toBeInTheDocument();
expect(screen.queryByLabelText('查车操作')).not.toBeInTheDocument(); expect(screen.queryByLabelText('查车操作')).not.toBeInTheDocument();
const toggle = screen.getByRole('button', { name: '修改车辆范围10 辆授权车辆' }); const toggle = screen.getByRole('button', { name: '打开车辆搜索10 辆授权车辆' });
expect(toggle).toHaveAttribute('aria-expanded', 'false'); expect(toggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(toggle); fireEvent.click(toggle);
const input = screen.getByRole('textbox', { name: '搜索车辆' }); const input = screen.getByRole('textbox', { name: '搜索车辆' });
@@ -291,20 +293,20 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi
expect(screen.getByRole('list', { name: '车辆搜索摘要' })).toHaveTextContent(`当前条件${items[0].plate}车牌 / VIN / 终端`); expect(screen.getByRole('list', { name: '车辆搜索摘要' })).toHaveTextContent(`当前条件${items[0].plate}车牌 / VIN / 终端`);
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8); expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
fireEvent.click(screen.getByRole('button', { name: '应用搜索' })); fireEvent.click(screen.getByRole('button', { name: '应用搜索' }));
await waitFor(() => expect(screen.getByRole('button', { name: `修改车辆范围${items[0].plate} · 10 辆匹配` })).toHaveAttribute('aria-expanded', 'false')); await waitFor(() => expect(screen.getByRole('button', { name: `打开车辆搜索${items[0].plate} · 10 辆匹配` })).toHaveAttribute('aria-expanded', 'false'));
expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: `修改车辆范围${items[0].plate} · 10 辆匹配` })); fireEvent.click(screen.getByRole('button', { name: `打开车辆搜索${items[0].plate} · 10 辆匹配` }));
fireEvent.change(screen.getByRole('textbox', { name: '搜索车辆' }), { target: { value: items[1].plate } }); fireEvent.change(screen.getByRole('textbox', { name: '搜索车辆' }), { target: { value: items[1].plate } });
fireEvent.click(screen.getByRole('button', { name: '关闭车辆搜索' })); fireEvent.click(screen.getByRole('button', { name: '关闭车辆搜索' }));
await waitFor(() => expect(screen.getByRole('button', { name: `修改车辆范围${items[0].plate} · 10 辆匹配` })).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole('button', { name: `打开车辆搜索${items[0].plate} · 10 辆匹配` })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: `修改车辆范围${items[0].plate} · 10 辆匹配` })); fireEvent.click(screen.getByRole('button', { name: `打开车辆搜索${items[0].plate} · 10 辆匹配` }));
fireEvent.click(screen.getByRole('button', { name: '重置范围' })); fireEvent.click(screen.getByRole('button', { name: '重置范围' }));
expect(screen.getByRole('dialog', { name: '车辆搜索' })).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: '车辆搜索' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: `关闭车辆范围${items[0].plate} · 10 辆匹配` })).toHaveAttribute('aria-expanded', 'true'); expect(screen.getByRole('button', { name: `关闭车辆搜索${items[0].plate} · 10 辆匹配` })).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '应用搜索' })); fireEvent.click(screen.getByRole('button', { name: '应用搜索' }));
await waitFor(() => expect(screen.getByRole('button', { name: '修改车辆范围10 辆授权车辆' })).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole('button', { name: '打开车辆搜索10 辆授权车辆' })).toBeInTheDocument());
expect(screen.getByRole('heading', { name: '授权车辆', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '授权车辆', level: 5 })).toBeInTheDocument();
}); });

View File

@@ -21,7 +21,7 @@ import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { PlatformTime } from '../shared/PlatformTime'; import { PlatformTime } from '../shared/PlatformTime';
import { ProtocolTag } from '../shared/ProtocolTag'; import { ProtocolTag } from '../shared/ProtocolTag';
import { SegmentedTabs } from '../shared/SegmentedTabs'; import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel'; import { VehicleSourceEvidenceButton, VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail'; import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
@@ -425,7 +425,7 @@ const vehicleSectionIDs = {
type VehicleSection = keyof typeof vehicleSectionIDs; type VehicleSection = keyof typeof vehicleSectionIDs;
function VehicleRecordNavigation() { function VehicleRecordNavigation({ onOpenSources }: { onOpenSources: () => void }) {
const jumpToSection = (section: VehicleSection) => { const jumpToSection = (section: VehicleSection) => {
const target = document.getElementById(vehicleSectionIDs[section]); const target = document.getElementById(vehicleSectionIDs[section]);
if (!target) return; if (!target) return;
@@ -442,8 +442,9 @@ function VehicleRecordNavigation() {
<WorkspacePanelHeader <WorkspacePanelHeader
variant="compact" variant="compact"
title="车辆视图" title="车辆视图"
description="位置、事件、遥测与主档" description="位置、事件、遥测"
actions={<nav className="v2-vehicle-section-nav" aria-label="单车详情导航"> actions={<nav className="v2-vehicle-section-nav" aria-label="单车详情导航">
<VehicleSourceEvidenceButton onClick={onOpenSources} />
{navigationItems.map(([key, label, icon]) => <Button key={key} size="small" theme="borderless" type="tertiary" icon={icon} aria-label={`跳转到${label}`} onClick={() => jumpToSection(key)}>{label}</Button>)} {navigationItems.map(([key, label, icon]) => <Button key={key} size="small" theme="borderless" type="tertiary" icon={icon} aria-label={`跳转到${label}`} onClick={() => jumpToSection(key)}>{label}</Button>)}
</nav>} </nav>}
/> />
@@ -587,8 +588,8 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
</section> </section>
</Card> </Card>
<VehicleSourceEvidencePanel vin={detail.vin} open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} /> <VehicleSourceEvidencePanel vin={detail.vin} hideTrigger open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
<VehicleRecordNavigation /> <VehicleRecordNavigation onOpenSources={() => setSourceEvidenceOpen(true)} />
<div className="v2-record-grid"> <div className="v2-record-grid">
<div id={vehicleSectionIDs.location} tabIndex={-1} className="v2-record-section-anchor v2-record-location-anchor"> <div id={vehicleSectionIDs.location} tabIndex={-1} className="v2-record-section-anchor v2-record-location-anchor">

View File

@@ -193,12 +193,13 @@ export default function VehicleSearchWorkspace() {
/> : null} /> : null}
{mobileLayout ? <div className="v2-vehicle-mobile-discovery"> {mobileLayout ? <div className="v2-vehicle-mobile-discovery">
<MobileFilterToggle <MobileFilterToggle
title="车辆范围" title="查找车辆"
summary={mobileFilterSummary} summary={mobileFilterSummary}
expanded={mobileFiltersOpen} expanded={mobileFiltersOpen}
controls="v2-vehicle-mobile-filter-dialog" controls="v2-vehicle-mobile-filter-dialog"
expandedLabel="关闭" expandedLabel="关闭"
collapsedLabel="修改" collapsedLabel="查找"
ariaLabel={`${mobileFiltersOpen ? '关闭' : '打开'}车辆搜索:${mobileFilterSummary}`}
onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters} onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters}
/> />
<MobileFilterSheet <MobileFilterSheet

View File

@@ -9,6 +9,7 @@ type MobileFilterToggleProps = {
controls?: string; controls?: string;
expandedLabel?: string; expandedLabel?: string;
collapsedLabel?: string; collapsedLabel?: string;
ariaLabel?: string;
}; };
export function MobileFilterToggle({ export function MobileFilterToggle({
@@ -18,7 +19,8 @@ export function MobileFilterToggle({
onToggle, onToggle,
controls, controls,
expandedLabel = '收起', expandedLabel = '收起',
collapsedLabel = '展开' collapsedLabel = '展开',
ariaLabel
}: MobileFilterToggleProps) { }: MobileFilterToggleProps) {
const actionLabel = expanded ? expandedLabel : collapsedLabel; const actionLabel = expanded ? expandedLabel : collapsedLabel;
return <Button return <Button
@@ -29,7 +31,7 @@ export function MobileFilterToggle({
htmlType="button" htmlType="button"
aria-expanded={expanded} aria-expanded={expanded}
aria-controls={controls} aria-controls={controls}
aria-label={`${actionLabel}${title}${summary}`} aria-label={ariaLabel || `${actionLabel}${title}${summary}`}
onClick={onToggle} onClick={onToggle}
> >
<span className="v2-mobile-filter-toggle-content"> <span className="v2-mobile-filter-toggle-content">

View File

@@ -79,14 +79,20 @@ function MileageSourceCard({ source }: { source: VehicleMileageSourceEvidence })
</Card>; </Card>;
} }
export function VehicleSourceEvidenceButton({ onClick }: { onClick: () => void }) {
return <Button size="small" theme="light" type="primary" icon={<IconSearch />} aria-label="来源证据" onClick={onClick}></Button>;
}
export function VehicleSourceEvidencePanel({ export function VehicleSourceEvidencePanel({
vin, vin,
compact = false, compact = false,
hideTrigger = false,
open, open,
onOpenChange onOpenChange
}: { }: {
vin: string; vin: string;
compact?: boolean; compact?: boolean;
hideTrigger?: boolean;
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
}) { }) {
@@ -133,12 +139,12 @@ export function VehicleSourceEvidencePanel({
</div>; </div>;
return <> return <>
<Card className={`v2-source-evidence${compact ? ' is-compact' : ''}${expanded ? ' is-open' : ''}${mobileLayout ? ' is-mobile-sheet' : ''}`} bodyStyle={{ padding: 0 }}> {!hideTrigger ? <Card className={`v2-source-evidence${compact ? ' is-compact' : ''}${expanded ? ' is-open' : ''}${mobileLayout ? ' is-mobile-sheet' : ''}`} bodyStyle={{ padding: 0 }}>
<header className="v2-source-evidence-trigger"> <header className="v2-source-evidence-trigger">
<div><strong></strong><span>{description}</span></div> <div><strong></strong><span>{description}</span></div>
<Button theme="light" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>{expanded ? '收起来源' : '查看全部来源'}</Button> <Button theme="light" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>{expanded ? '收起来源' : '查看全部来源'}</Button>
</header> </header>
</Card> </Card> : null}
<WorkspaceDetailSideSheet <WorkspaceDetailSideSheet
className="v2-source-evidence-sidesheet" className="v2-source-evidence-sidesheet"
visible={expanded} visible={expanded}

View File

@@ -25655,6 +25655,15 @@
transition: background-color .16s ease; transition: background-color .16s ease;
} }
.v2-vehicle-section-nav > .semi-button:first-child {
min-width: 82px;
min-height: 34px;
border-radius: 8px;
padding-inline: 9px;
font-size: 9px;
font-weight: 700;
}
.v2-vehicle-recent-card > .semi-card-body > .v2-vehicle-directory-pagination { .v2-vehicle-recent-card > .semi-card-body > .v2-vehicle-directory-pagination {
background: #fbfcfe; background: #fbfcfe;
box-shadow: 0 -8px 22px rgba(43, 62, 86, .025); box-shadow: 0 -8px 22px rgba(43, 62, 86, .025);
@@ -25701,6 +25710,18 @@
.v2-vehicle-directory-v3 .v2-vehicle-recent-item-shell.is-offline { .v2-vehicle-directory-v3 .v2-vehicle-recent-item-shell.is-offline {
box-shadow: inset 2px 0 #c9d2de; box-shadow: inset 2px 0 #c9d2de;
} }
.v2-vehicle-record-v3.is-mobile-layout .v2-vehicle-section-nav {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.v2-vehicle-record-v3.is-mobile-layout .v2-vehicle-section-nav > .semi-button:first-child {
width: 100%;
min-width: 0;
min-height: 36px;
padding-inline: 2px;
font-size: 8px;
}
} }
/* /*