feat: unify track replay summary

This commit is contained in:
lingniu
2026-07-19 11:18:16 +08:00
parent 1be76c1dfa
commit a82bb28af9
4 changed files with 129 additions and 87 deletions

View File

@@ -179,7 +179,14 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
expect(view.container.querySelector('.v2-track-query-panel')).toHaveClass('is-collapsed');
expect(view.container.querySelector('.v2-track-query-summary')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-track-rail-result')).toBeInTheDocument();
expect(view.container.querySelector('.v2-track-result-header')).toHaveTextContent('轨迹证据粤A12345 · LTEST0000000000018 km');
const tripSummary = screen.getByRole('list', { name: '轨迹行程摘要' });
expect(tripSummary.closest('.v2-track-result-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue');
expect(Array.from(tripSummary.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['行程里程', '停留点', '事件点', '数据点']);
expect(tripSummary).toHaveTextContent('行程里程8 km00:20:00');
expect(tripSummary).toHaveTextContent('停留点1超过 3 分钟');
expect(tripSummary).toHaveTextContent('事件点3轨迹事件');
expect(tripSummary).toHaveTextContent('数据点3时间窗完整');
expect(view.container.querySelector('.v2-track-result-header')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '修改条件' })).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-track-current-card.semi-card')).toBeInTheDocument();
@@ -196,7 +203,7 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
expect(screen.getByRole('button', { name: /停留 00:05:00/ }).closest('.semi-list-item')).toHaveClass('v2-track-evidence-item');
expect(view.container.querySelector('.v2-track-evidence-list.semi-list')).toBeInTheDocument();
expect(screen.getByRole('slider', { name: '轨迹播放进度' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-track-dock-summary')).toHaveTextContent('行程里程8 km00:20:00 · 1 次停留');
expect(view.container.querySelector('.v2-track-dock-summary')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-track-progress-meta')).toHaveTextContent('已暂停 · 数据点 1 / 3');
expect(screen.queryByRole('button', { name: '暂停轨迹播放' })).not.toBeInTheDocument();
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(1));
@@ -277,7 +284,12 @@ test('uses the shared Semi editor SideSheet for mobile track criteria and eviden
expect(screen.queryByLabelText('轨迹查询任务栏')).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
expect(screen.getAllByText('07-15 00:00 07-15 23:59 · 自动来源')).toHaveLength(2);
expect(screen.getByRole('list', { name: '轨迹查询与明细摘要' })).toHaveTextContent('行程里程8 km00:20:00停留 / 事件1 / 3地图证据点数据点3时间窗完整');
const mobileSummary = screen.getByRole('list', { name: '轨迹查询与明细摘要' });
expect(mobileSummary).toHaveTextContent('行程里程8 km00:20:00');
expect(mobileSummary).toHaveTextContent('停留点1超过 3 分钟');
expect(mobileSummary).toHaveTextContent('事件点3轨迹事件');
expect(mobileSummary).toHaveTextContent('数据点3时间窗完整');
expect(mobileSummary.querySelectorAll(':scope > [role="listitem"]')).toHaveLength(4);
expect(screen.getByRole('button', { name: '修改轨迹查询' })).toHaveTextContent('修改查询');
expect(screen.getByRole('button', { name: /查看地图$/ })).toBeInTheDocument();
expect(sheet).toHaveStyle({ height: 'min(88dvh, 394px)' });

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 { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { QUERY_MEMORY } from '../queryPolicy';
@@ -127,6 +128,39 @@ function mobileTrackSheetHeight(track: TrackPlaybackResponse | undefined, queryC
return `min(88dvh, ${contentHeight}px)`;
}
function trackSummaryItems(track?: TrackPlaybackResponse): WorkspaceQueueMetricRailItem[] {
return [
{
label: '行程里程',
value: track ? `${number(track.summary.distanceKm)} km` : '—',
note: track ? formatDuration(track.summary.durationSeconds) : '等待查询',
tone: 'primary',
emphasis: 'primary'
},
{
label: '停留点',
value: track ? track.stops.length.toLocaleString('zh-CN') : '—',
note: track ? '超过 3 分钟' : '等待查询',
tone: track?.stops.length ? 'warning' : 'neutral',
emphasis: 'secondary'
},
{
label: '事件点',
value: track ? track.events.length.toLocaleString('zh-CN') : '—',
note: track ? '轨迹事件' : '等待查询',
tone: 'neutral',
emphasis: 'secondary'
},
{
label: '数据点',
value: track ? track.points.length.toLocaleString('zh-CN') : '—',
note: track ? track.coverage.complete ? '时间窗完整' : '已返回切片' : '等待查询',
tone: track?.coverage.complete ? 'success' : track ? 'warning' : 'neutral',
emphasis: 'secondary'
}
];
}
function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: (value: string) => void; onSelect: (vehicle: VehicleRow) => void }) {
const [open, setOpen] = useState(false);
const [debounced, setDebounced] = useState(value.trim());
@@ -195,9 +229,10 @@ function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
</div>;
}
const TrackRail = memo(function TrackRail({ draft, track, loading, activeStopIndexes, activeEventIndexes, tab, queryCollapsed, showCommandBar, onDraft, onSubmit, onTab, onSelectIndex, onToggleQuery, onCollapse }: {
const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading, activeStopIndexes, activeEventIndexes, tab, queryCollapsed, showCommandBar, onDraft, onSubmit, onTab, onSelectIndex, onToggleQuery, onCollapse }: {
draft: Draft;
track?: TrackPlaybackResponse;
summaryItems: WorkspaceQueueMetricRailItem[];
loading: boolean;
activeStopIndexes: readonly number[];
activeEventIndexes: readonly number[];
@@ -254,12 +289,11 @@ const TrackRail = memo(function TrackRail({ draft, track, loading, activeStopInd
</section>
<section className="v2-track-rail-result">
<WorkspacePanelHeader
variant="compact"
className="v2-track-result-header"
title="轨迹证据"
description={track ? track.plate ? `${track.plate} · ${track.vin}` : track.vin : '停留、事件与来源统一呈现'}
meta={<Tag color={track ? 'blue' : 'grey'} type="light" size="small">{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</Tag>}
<WorkspaceMetricRail
variant="queue"
ariaLabel="轨迹行程摘要"
className="v2-track-result-metric-rail"
items={summaryItems}
/>
<SegmentedTabs className="v2-track-rail-tabs" ariaLabel="轨迹明细分类" value={tab} onChange={onTab} items={[{ key: 'stops', label: '停留点', count: track?.stops.length ?? 0 }, { key: 'events', label: '事件点', count: track?.events.length ?? 0 }, { key: 'overview', label: '概览' }]} />
<div className="v2-track-rail-scroll">
@@ -386,6 +420,7 @@ export default function TrackPage() {
if (mobileLayout) setRailCollapsed(true);
}, [mobileLayout, selectIndex]);
const collapseRail = useCallback(() => setRailCollapsed(true), []);
const toggleQuery = useCallback(() => setQueryCollapsed((value) => !value), []);
const togglePlayback = () => {
if (points.length < 2) return;
if (!playing && boundedIndex >= points.length - 1) setActiveIndex(0);
@@ -393,7 +428,8 @@ export default function TrackPage() {
setPlaying((value) => !value);
};
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
const trackRail = <TrackRail draft={draft} track={track} loading={query.isFetching} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} queryCollapsed={queryCollapsed} showCommandBar={!mobileLayout} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectRailIndex} onToggleQuery={() => setQueryCollapsed((value) => !value)} onCollapse={collapseRail} />;
const tripSummaryItems = useMemo(() => trackSummaryItems(track), [track]);
const trackRail = <TrackRail draft={draft} track={track} summaryItems={tripSummaryItems} loading={query.isFetching} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} queryCollapsed={queryCollapsed} showCommandBar={!mobileLayout} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectRailIndex} onToggleQuery={toggleQuery} onCollapse={collapseRail} />;
const appliedWindow = `${draft.dateFrom.replace('T', ' ').slice(5)} ${draft.dateTo.replace('T', ' ').slice(5)}`;
const appliedSource = draft.protocol ? protocolDisplayLabel(draft.protocol) : '自动来源';
const mobileSheetTitle = track?.plate || draft.keyword.trim() || '轨迹查询与明细';
@@ -405,29 +441,16 @@ export default function TrackPage() {
: track
? track.coverage.complete ? '时间窗完整' : '最新切片'
: '待查询';
const mobileSheetSummary = track ? [
{
label: '行程里程',
value: `${number(track.summary.distanceKm)} km`,
detail: formatDuration(track.summary.durationSeconds),
tone: 'primary' as const
},
{
label: '停留 / 事件',
value: `${track.stops.length} / ${track.events.length}`,
detail: '地图证据点'
},
{
label: '数据点',
value: track.points.length.toLocaleString('zh-CN'),
detail: track.coverage.complete ? '时间窗完整' : '已返回切片',
tone: track.coverage.complete ? 'success' as const : 'warning' as const
}
] : [
const mobileSheetSummary = useMemo(() => track ? tripSummaryItems.map((item) => ({
label: item.label,
value: item.value,
detail: item.note,
tone: item.tone
})) : [
{ label: '查询状态', value: '待查询', detail: '请先选择车辆', tone: 'primary' as const },
{ label: '时间范围', value: appliedWindow, detail: '最长连续 7 天' },
{ label: '数据来源', value: appliedSource, detail: '支持自动优选' }
];
], [appliedSource, appliedWindow, track, tripSummaryItems]);
return <div className={`v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn ? ' has-monitor-return' : ''}`}>
<MonitorReturnBar />
@@ -451,7 +474,7 @@ export default function TrackPage() {
label: queryCollapsed ? '修改查询' : '收起查询',
ariaLabel: queryCollapsed ? '修改轨迹查询' : '收起轨迹查询',
icon: <IconSearch />,
onClick: () => setQueryCollapsed((value) => !value)
onClick: toggleQuery
}]}
primaryAction={{ label: '查看地图', icon: <IconMapPin />, onClick: collapseRail }}
onCancel={collapseRail}
@@ -494,7 +517,6 @@ export default function TrackPage() {
{query.isError ? <div className="v2-track-error"><InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /></div> : null}
<Card className="v2-track-playback-dock" bodyStyle={{ padding: 0 }}>
<div className="v2-track-dock-summary"><small></small><strong>{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</strong><span>{track ? `${formatDuration(track.summary.durationSeconds)} · ${track.summary.stopCount} 次停留` : '查询后可播放完整轨迹'}</span></div>
<div className="v2-track-dock-progress">
{track ? <SegmentRail track={track} /> : <div className="v2-track-segment-placeholder" />}
<Slider key={`track-progress-${points.length}`} className="v2-track-progress-slider" aria-label="轨迹播放进度" min={0} max={Math.max(0, points.length - 1)} step={1} value={boundedIndex} onChange={(value) => selectIndex(Number(value))} disabled={!points.length} showBoundary={false} tipFormatter={(value) => `${Number(value) + 1} / ${points.length} 个数据点`} />

View File

@@ -392,6 +392,10 @@ describe('V2 production entry', () => {
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-rail-shell"');
expect(corePageSources.TrackPage).toContain('<section className={`v2-track-query-panel${queryCollapsed');
expect(corePageSources.TrackPage).toContain('<section className="v2-track-rail-result">');
expect(corePageSources.TrackPage).toContain("from '../shared/WorkspaceMetricRail'");
expect(corePageSources.TrackPage).toContain('<WorkspaceMetricRail');
expect(corePageSources.TrackPage).toContain('className="v2-track-result-metric-rail"');
expect(corePageSources.TrackPage).not.toContain('className="v2-track-result-header"');
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-current-card"');
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-empty-state"');
expect(corePageSources.TrackPage).toContain('<Card className="v2-track-playback-dock"');

View File

@@ -1607,29 +1607,6 @@
background: #fff;
}
.v2-track-result-header {
min-height: 64px;
background: #fbfcfe;
padding: 10px 14px;
}
.v2-track-result-header .v2-workspace-panel-copy > h5.semi-typography {
color: #25384f;
font-size: 15px;
}
.v2-track-result-header .v2-workspace-panel-copy > .semi-typography {
color: #8592a3;
font-size: 11px;
}
.v2-track-result-header .v2-workspace-panel-meta .semi-tag {
border-radius: 999px;
padding-inline: 10px;
font-size: 11px;
font-weight: 700;
}
.v2-track-rail-tabs {
height: 46px;
flex-basis: 46px;
@@ -1935,37 +1912,11 @@
.v2-track-playback-dock > .semi-card-body {
min-height: 104px;
grid-template-columns: 150px minmax(240px, 1fr) auto;
grid-template-columns: minmax(240px, 1fr) auto;
gap: 16px;
padding: 11px 16px !important;
}
.v2-track-dock-summary small,
.v2-track-dock-summary strong,
.v2-track-dock-summary span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-track-dock-summary small {
margin-bottom: 2px;
color: #8593a5;
font-size: 9px;
font-weight: 650;
letter-spacing: .04em;
}
.v2-track-dock-summary strong {
font-size: 20px;
}
.v2-track-dock-summary span {
margin-top: 3px;
font-size: 10px;
}
.v2-track-segment-rail,
.v2-track-segment-placeholder {
height: 8px;
@@ -2028,9 +1979,6 @@
grid-template-columns: minmax(220px, 1fr) auto;
}
.v2-track-dock-summary {
display: none;
}
}
@media (max-width: 700px) {
@@ -2055,7 +2003,7 @@
max-height: 156px;
}
.v2-track-detail-sidesheet .v2-track-result-header {
.v2-track-detail-sidesheet .v2-track-result-metric-rail {
display: none;
}
@@ -23162,6 +23110,62 @@
}
}
/*
* Track replay trip summary.
* The narrow evidence rail uses the same Semi metric language as monitor and
* vehicle workspaces, while playback controls remain focused on time and
* transport instead of repeating trip totals.
*/
.v2-track-result-metric-rail.is-queue.semi-card {
flex: 0 0 auto;
border-bottom-color: #dfe6ef;
}
.v2-track-result-metric-rail.is-queue > .semi-card-body {
min-height: 64px;
grid-template-columns: minmax(0, 1fr);
}
.v2-track-result-metric-rail .v2-workspace-metric-list {
grid-template-columns: 1.18fr .9fr .9fr 1fr;
}
.v2-track-result-metric-rail .v2-workspace-metric-list > span {
min-height: 64px;
grid-template-columns: minmax(0, 1fr);
column-gap: 0;
padding: 7px 8px;
}
.v2-track-result-metric-rail .v2-workspace-metric-list > span > :is(small, strong) {
overflow: hidden;
text-overflow: ellipsis;
}
.v2-track-result-metric-rail .v2-workspace-metric-list > span > small {
font-size: 8px;
}
.v2-track-result-metric-rail .v2-workspace-metric-list > span > strong,
.v2-track-result-metric-rail .v2-workspace-metric-list > span.is-secondary > strong {
font-size: clamp(14px, 1.2vw, 17px);
line-height: 20px;
}
.v2-track-result-metric-rail .v2-workspace-metric-list > span > em {
display: none;
}
@media (max-width: 680px) {
.v2-track-detail-sidesheet .v2-workspace-config-summary {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.v2-track-detail-sidesheet .v2-workspace-config-summary > span {
padding-inline: 8px;
}
}
/*
* Monitor fleet summary.
* Operational state remains prominent; high-volume ingest evidence is kept