feat(platform): link trajectory playback selection
This commit is contained in:
@@ -109,6 +109,10 @@ function timeValue(row?: HistoryLocationRow) {
|
|||||||
return Number.isFinite(ms) ? ms : undefined;
|
return Number.isFinite(ms) ? ms : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playbackPointId(row: HistoryLocationRow, index: number) {
|
||||||
|
return `${row.vin || 'unknown'}-${row.deviceTime || row.serverTime || row.lastSeen || index}-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDurationMinutes(value?: number, suffix = '') {
|
function formatDurationMinutes(value?: number, suffix = '') {
|
||||||
if (!isFiniteNumber(value)) return '-';
|
if (!isFiniteNumber(value)) return '-';
|
||||||
if (value < 1) return `${Math.round(value * 60)} 秒${suffix}`;
|
if (value < 1) return `${Math.round(value * 60)} 秒${suffix}`;
|
||||||
@@ -444,9 +448,10 @@ export function History({
|
|||||||
const playbackRows = validLocations.length > 0 ? validLocations : locationItems;
|
const playbackRows = validLocations.length > 0 ? validLocations : locationItems;
|
||||||
const currentPlaybackIndex = playbackRows.length === 0 ? -1 : Math.min(playbackIndex, playbackRows.length - 1);
|
const currentPlaybackIndex = playbackRows.length === 0 ? -1 : Math.min(playbackIndex, playbackRows.length - 1);
|
||||||
const currentPlayback = currentPlaybackIndex >= 0 ? playbackRows[currentPlaybackIndex] : undefined;
|
const currentPlayback = currentPlaybackIndex >= 0 ? playbackRows[currentPlaybackIndex] : undefined;
|
||||||
|
const currentPlaybackPointId = currentPlayback ? playbackPointId(currentPlayback, currentPlaybackIndex) : undefined;
|
||||||
const playbackAtEnd = playbackRows.length > 0 && currentPlaybackIndex >= playbackRows.length - 1;
|
const playbackAtEnd = playbackRows.length > 0 && currentPlaybackIndex >= playbackRows.length - 1;
|
||||||
const playbackPoints: VehicleMapPoint[] = validLocations.map((row, index) => ({
|
const playbackPoints: VehicleMapPoint[] = validLocations.map((row, index) => ({
|
||||||
id: `${row.vin}-${row.deviceTime || row.serverTime || index}`,
|
id: playbackPointId(row, index),
|
||||||
label: row.plate || row.vin || `点 ${index + 1}`,
|
label: row.plate || row.vin || `点 ${index + 1}`,
|
||||||
longitude: row.longitude,
|
longitude: row.longitude,
|
||||||
latitude: row.latitude,
|
latitude: row.latitude,
|
||||||
@@ -539,6 +544,17 @@ export function History({
|
|||||||
setPlaybackPlaying(false);
|
setPlaybackPlaying(false);
|
||||||
setPlaybackIndex((current) => Math.min(Math.max(current + delta, 0), playbackRows.length - 1));
|
setPlaybackIndex((current) => Math.min(Math.max(current + delta, 0), playbackRows.length - 1));
|
||||||
};
|
};
|
||||||
|
const selectPlaybackPoint = (nextIndex: number) => {
|
||||||
|
if (playbackRows.length === 0) return;
|
||||||
|
setPlaybackPlaying(false);
|
||||||
|
setPlaybackIndex(Math.min(Math.max(nextIndex, 0), playbackRows.length - 1));
|
||||||
|
};
|
||||||
|
const selectPlaybackMapPoint = (point: VehicleMapPoint) => {
|
||||||
|
const index = playbackRows.findIndex((row, rowIndex) => playbackPointId(row, rowIndex) === point.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
selectPlaybackPoint(index);
|
||||||
|
}
|
||||||
|
};
|
||||||
const openPlaybackVehicle = () => {
|
const openPlaybackVehicle = () => {
|
||||||
if (!currentPlayback || !canOpenVehicle(currentPlayback.vin)) return;
|
if (!currentPlayback || !canOpenVehicle(currentPlayback.vin)) return;
|
||||||
onOpenVehicle(currentPlayback.vin, currentPlayback.protocol);
|
onOpenVehicle(currentPlayback.vin, currentPlayback.protocol);
|
||||||
@@ -648,6 +664,8 @@ export function History({
|
|||||||
<VehicleMap
|
<VehicleMap
|
||||||
points={playbackPoints}
|
points={playbackPoints}
|
||||||
mode="track"
|
mode="track"
|
||||||
|
selectedId={currentPlaybackPointId}
|
||||||
|
onPointSelect={selectPlaybackMapPoint}
|
||||||
fallbackLabel="高德地图未配置,显示轨迹坐标预览"
|
fallbackLabel="高德地图未配置,显示轨迹坐标预览"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -663,7 +681,7 @@ export function History({
|
|||||||
<div className="vp-monitor-metric-value">{item.value}</div>
|
<div className="vp-monitor-metric-value">{item.value}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="vp-playback-current">
|
<div className="vp-playback-current">
|
||||||
<div className="vp-map-service-queue-title">当前回放点</div>
|
<div className="vp-map-service-queue-title">当前回放点</div>
|
||||||
{currentPlayback ? (
|
{currentPlayback ? (
|
||||||
<>
|
<>
|
||||||
@@ -703,6 +721,16 @@ export function History({
|
|||||||
<Button size="small" disabled={currentPlaybackIndex >= playbackRows.length - 1} onClick={() => movePlayback(1)}>下一点</Button>
|
<Button size="small" disabled={currentPlaybackIndex >= playbackRows.length - 1} onClick={() => movePlayback(1)}>下一点</Button>
|
||||||
<Button size="small" disabled={!canOpenVehicle(currentPlayback.vin)} onClick={openPlaybackVehicle}>回放点车辆服务</Button>
|
<Button size="small" disabled={!canOpenVehicle(currentPlayback.vin)} onClick={openPlaybackVehicle}>回放点车辆服务</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
<input
|
||||||
|
className="vp-playback-range"
|
||||||
|
aria-label="轨迹回放进度"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={Math.max(playbackRows.length - 1, 0)}
|
||||||
|
step={1}
|
||||||
|
value={currentPlaybackIndex}
|
||||||
|
onChange={(event) => selectPlaybackPoint(Number(event.currentTarget.value))}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text type="tertiary">暂无可回放位置点</Typography.Text>
|
<Typography.Text type="tertiary">暂无可回放位置点</Typography.Text>
|
||||||
@@ -712,7 +740,13 @@ export function History({
|
|||||||
</div>
|
</div>
|
||||||
<div className="vp-playback-timeline">
|
<div className="vp-playback-timeline">
|
||||||
{(validLocations.length > 0 ? validLocations : locationItems).slice(0, 8).map((row, index) => (
|
{(validLocations.length > 0 ? validLocations : locationItems).slice(0, 8).map((row, index) => (
|
||||||
<div key={`${row.deviceTime}-${index}`} className="vp-playback-step">
|
<button
|
||||||
|
key={`${row.deviceTime}-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={`vp-playback-step ${index === currentPlaybackIndex ? 'vp-playback-step-active' : ''}`}
|
||||||
|
aria-label={`选择轨迹点 ${index + 1}`}
|
||||||
|
onClick={() => selectPlaybackPoint(index)}
|
||||||
|
>
|
||||||
<Tag color={index === 0 ? 'green' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? 'orange' : 'blue'}>
|
<Tag color={index === 0 ? 'green' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? 'orange' : 'blue'}>
|
||||||
{index === 0 ? '起点' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? '终点' : `点 ${index + 1}`}
|
{index === 0 ? '起点' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? '终点' : `点 ${index + 1}`}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -720,7 +754,7 @@ export function History({
|
|||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
{formatNumber(row.speedKmh, ' km/h')} / {formatNumber(row.totalMileageKm, ' km')}
|
{formatNumber(row.speedKmh, ' km/h')} / {formatNumber(row.totalMileageKm, ' km')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
{locationItems.length === 0 ? (
|
{locationItems.length === 0 ? (
|
||||||
<Typography.Text type="secondary">当前筛选范围暂无轨迹点,请调整车辆、来源或时间范围。</Typography.Text>
|
<Typography.Text type="secondary">当前筛选范围暂无轨迹点,请调整车辆、来源或时间范围。</Typography.Text>
|
||||||
|
|||||||
@@ -687,11 +687,33 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vp-playback-step {
|
.vp-playback-step {
|
||||||
|
text-align: left;
|
||||||
min-height: 112px;
|
min-height: 112px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid var(--vp-border);
|
border: 1px solid var(--vp-border);
|
||||||
border-radius: var(--vp-radius);
|
border-radius: var(--vp-radius);
|
||||||
background: #fbfcff;
|
background: #fbfcff;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.16s ease, box-shadow 0.16s ease, background 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-playback-step:hover,
|
||||||
|
.vp-playback-step:focus-visible {
|
||||||
|
border-color: rgba(22, 100, 255, 0.42);
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 100, 255, 0.08);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-playback-step-active {
|
||||||
|
border-color: rgba(22, 100, 255, 0.72);
|
||||||
|
background: #eef5ff;
|
||||||
|
box-shadow: inset 3px 0 0 #1664ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-playback-range {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: #1664ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vp-json {
|
.vp-json {
|
||||||
|
|||||||
@@ -5843,6 +5843,15 @@ test('controls trajectory playback current point from history locations', async
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '下一点' }));
|
fireEvent.click(screen.getByRole('button', { name: '下一点' }));
|
||||||
expect(screen.getByText('点 2 / 3')).toBeInTheDocument();
|
expect(screen.getByText('点 2 / 3')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('2026-07-03 10:10:00').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('2026-07-03 10:10:00').length).toBeGreaterThan(0);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '选择轨迹点 3' }));
|
||||||
|
expect(screen.getByText('点 3 / 3')).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByRole('slider', { name: '轨迹回放进度' }), { target: { value: '0' } });
|
||||||
|
expect(screen.getByText('点 1 / 3')).toBeInTheDocument();
|
||||||
|
const mapPointButtons = screen.getAllByRole('button', { name: '选择地图车辆 粤A回放1' });
|
||||||
|
fireEvent.click(mapPointButtons[0]);
|
||||||
|
expect(screen.getByText('点 1 / 3')).toBeInTheDocument();
|
||||||
|
fireEvent.click(mapPointButtons[1]);
|
||||||
|
expect(screen.getByText('点 2 / 3')).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '上一点' }));
|
fireEvent.click(screen.getByRole('button', { name: '上一点' }));
|
||||||
expect(screen.getByText('点 1 / 3')).toBeInTheDocument();
|
expect(screen.getByText('点 1 / 3')).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '回放点车辆服务' }));
|
fireEvent.click(screen.getByRole('button', { name: '回放点车辆服务' }));
|
||||||
|
|||||||
Reference in New Issue
Block a user