feat(platform): customerize realtime map workspace
This commit is contained in:
@@ -455,7 +455,7 @@ export default function App() {
|
||||
const pages: Record<PageKey, JSX.Element> = {
|
||||
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={openQuality} onOpenMap={openMap} onOpenRealtime={openRealtime} onOpenVehicles={openVehicles} onOpenHistory={openHistoryWithFilters} onOpenMileage={openMileageWithFilters} />,
|
||||
vehicles: <Vehicles onOpenVehicle={openVehicle} onOpenQuality={openQuality} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
||||
map: <Realtime title="实时地图" description="以车辆服务为中心查看实时位置、在线状态、来源一致性和高德地图接入状态" onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onOpenQuality={openQuality} onFiltersChange={updateMapFilters} initialFilters={realtimeFilters} />,
|
||||
map: <Realtime mode="map" title="车辆实时地图" description="以车辆为中心查看位置分布、在线状态、关注车辆和轨迹入口" onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onOpenQuality={openQuality} onFiltersChange={updateMapFilters} initialFilters={realtimeFilters} />,
|
||||
realtime: <Realtime onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onOpenQuality={openQuality} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
|
||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} platformRelease={platformRelease} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onOpenQuality={openQuality} onOpenHistoryEvidence={openHistoryWithFilters} onOpenRawEvidence={openRawWithFilters} onQueryChange={updateVehicleDetailQuery} />,
|
||||
history: <History mode="trajectory" initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
|
||||
|
||||
@@ -471,6 +471,7 @@ async function copyText(value: string, label: string) {
|
||||
}
|
||||
|
||||
export function Realtime({
|
||||
mode = 'realtime',
|
||||
title = '实时监控',
|
||||
description = '以车辆为主对象查看最新位置、在线来源、核心实时数据和地图作业状态',
|
||||
onOpenVehicle,
|
||||
@@ -479,6 +480,7 @@ export function Realtime({
|
||||
onFiltersChange,
|
||||
initialFilters = {}
|
||||
}: {
|
||||
mode?: 'realtime' | 'map';
|
||||
title?: string;
|
||||
description?: string;
|
||||
onOpenVehicle: (vin: string, protocol?: string) => void;
|
||||
@@ -718,6 +720,156 @@ export function Realtime({
|
||||
const selectRealtimeMapPoint = (point: VehicleMapPoint) => {
|
||||
setSelectedMapPointId(point.id);
|
||||
};
|
||||
const mapAttentionRows = rows
|
||||
.filter((row) => canOpenVehicle(row.vin) && (dataFreshness(row).stale || !isValidCoordinate(row) || hasSourceIssue(row)))
|
||||
.sort((a, b) => serviceStatusWeight(b) - serviceStatusWeight(a) || String(b.lastSeen ?? '').localeCompare(String(a.lastSeen ?? '')))
|
||||
.slice(0, 8);
|
||||
const mapFleetKpis = [
|
||||
{ label: '车辆总数', value: pagination.total.toLocaleString(), color: 'blue' as const, helper: `当前页 ${rows.length.toLocaleString()} 辆` },
|
||||
{ label: '在线车辆', value: onlineCount.toLocaleString(), color: onlineCount > 0 ? 'green' as const : 'orange' as const, helper: `在线率 ${formatPercent(onlineRate)}` },
|
||||
{ label: '有效定位', value: locatedCount.toLocaleString(), color: locatedCount > 0 ? 'green' as const : 'orange' as const, helper: `定位率 ${formatPercent(locatedRate)}` },
|
||||
{ label: '需关注', value: mapAttentionRows.length.toLocaleString(), color: mapAttentionRows.length > 0 ? 'orange' as const : 'green' as const, helper: `${staleCount.toLocaleString()} 辆更新超时` }
|
||||
];
|
||||
|
||||
if (mode === 'map') {
|
||||
return (
|
||||
<div className="vp-page vp-map-page">
|
||||
<PageHeader title={title} description={description} />
|
||||
<div className="vp-map-commandbar">
|
||||
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
||||
applyFilters(values as Record<string, string>);
|
||||
}}>
|
||||
<Form.Input field="keyword" label="车辆" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 260 }} />
|
||||
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 160 }}>
|
||||
<Select.Option value="GB32960">GB32960</Select.Option>
|
||||
<Select.Option value="JT808">JT808</Select.Option>
|
||||
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
|
||||
</Form.Select>
|
||||
<Form.Select field="online" label="状态" placeholder="全部车辆" style={{ width: 130 }}>
|
||||
<Select.Option value="online">在线</Select.Option>
|
||||
<Select.Option value="offline">离线</Select.Option>
|
||||
</Form.Select>
|
||||
<Space>
|
||||
<Button htmlType="submit" theme="solid" type="primary">刷新地图</Button>
|
||||
<Button onClick={() => applyFilters({})}>重置</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
<Space wrap className="vp-map-refresh-actions">
|
||||
<Tag color={autoRefresh ? 'green' : 'grey'}>{autoRefresh ? `自动刷新 ${refreshIntervalSeconds}秒` : '自动刷新已暂停'}</Tag>
|
||||
<Typography.Text type="secondary">最后刷新:{lastRefreshAt || '等待首次刷新'}</Typography.Text>
|
||||
<Button size="small" theme="light" loading={loading} onClick={() => load(filters, pagination.currentPage, pagination.pageSize)}>立即刷新</Button>
|
||||
<Button size="small" theme="light" onClick={() => setAutoRefresh((value) => !value)}>{autoRefresh ? '暂停刷新' : '开启刷新'}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="vp-map-kpi-strip">
|
||||
{mapFleetKpis.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="vp-map-kpi-item"
|
||||
onClick={() => {
|
||||
if (item.label === '在线车辆') applyFilters({ ...filters, online: 'online' });
|
||||
if (item.label === '需关注') applyFilters({ ...filters, serviceStatus: 'degraded' });
|
||||
}}
|
||||
>
|
||||
<Tag color={item.color}>{item.label}</Tag>
|
||||
<strong>{item.value}</strong>
|
||||
<span>{item.helper}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="vp-map-workspace">
|
||||
<section className="vp-map-canvas-panel">
|
||||
<div className="vp-map-canvas-toolbar">
|
||||
<Space wrap>
|
||||
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德地图可用' : '坐标预览'}</Tag>
|
||||
<Tag color="blue">{locatedCount.toLocaleString()} 辆有效定位</Tag>
|
||||
{filterSummary.map((item) => <Tag key={item} color="grey">{item}</Tag>)}
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Button size="small" theme="light" onClick={exportRealtime}>导出当前车辆</Button>
|
||||
<Button size="small" theme="light" disabled={!onOpenQuality} onClick={() => onOpenQuality?.({ serviceStatus: 'degraded' })}>查看告警</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<VehicleMap
|
||||
points={mapPoints}
|
||||
maxFallbackPoints={160}
|
||||
selectedId={selectedMapPointKey}
|
||||
onPointSelect={selectRealtimeMapPoint}
|
||||
heightClassName="vp-map-customer-canvas"
|
||||
fallbackLabel="地图加载中,先显示车辆坐标态势"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside className="vp-map-customer-side">
|
||||
{selectedMapRow ? (
|
||||
<div className="vp-map-customer-card vp-map-selected-card">
|
||||
<div className="vp-map-card-title">选中车辆</div>
|
||||
<Space wrap>
|
||||
<Tag color={vehicleServiceStatus(selectedMapRow).color}>{vehicleServiceStatus(selectedMapRow).label}</Tag>
|
||||
<Tag color={dataFreshness(selectedMapRow).color}>{dataFreshness(selectedMapRow).detail}</Tag>
|
||||
<Tag color="blue">{selectedMapRow.primaryProtocol || '未知来源'}</Tag>
|
||||
</Space>
|
||||
<Typography.Title heading={5} style={{ margin: '8px 0 0' }}>
|
||||
{selectedMapRow.plate || selectedMapRow.vin}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="tertiary">{selectedMapRow.vin}</Typography.Text>
|
||||
<div className="vp-map-selected-metrics">
|
||||
<span>速度</span><strong>{selectedMapRow.speedKmh ?? '-'} km/h</strong>
|
||||
<span>SOC</span><strong>{selectedMapRow.socPercent ?? '-'}%</strong>
|
||||
<span>总里程</span><strong>{selectedMapRow.totalMileageKm ?? '-'} km</strong>
|
||||
<span>最后上报</span><strong>{selectedMapRow.lastSeen || '-'}</strong>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button size="small" theme="solid" type="primary" onClick={() => onOpenVehicle(selectedMapRow.vin, filters.protocol || selectedMapRow.primaryProtocol)}>车辆档案</Button>
|
||||
<Button size="small" onClick={() => onOpenHistory?.(selectedMapRow.vin, filters.protocol || selectedMapRow.primaryProtocol)}>轨迹回放</Button>
|
||||
<Button size="small" disabled={!isValidCoordinate(selectedMapRow)} onClick={() => window.open(amapMarkerURL(selectedMapRow), '_blank', 'noopener,noreferrer')}>高德坐标</Button>
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="vp-map-customer-card">
|
||||
<div className="vp-map-card-title">关注车辆</div>
|
||||
{mapAttentionRows.length === 0 ? (
|
||||
<Typography.Text type="tertiary">当前筛选下暂无需要关注的车辆。</Typography.Text>
|
||||
) : mapAttentionRows.map((row) => {
|
||||
const status = vehicleServiceStatus(row);
|
||||
return (
|
||||
<button key={`${row.vin}-${row.primaryProtocol}-map-attention`} type="button" className="vp-map-attention-row" onClick={() => selectRealtimeRow(row)}>
|
||||
<div>
|
||||
<Typography.Text strong>{row.plate || row.vin}</Typography.Text>
|
||||
<Typography.Text type="tertiary" size="small">{row.vin}</Typography.Text>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
<Typography.Text type="secondary" size="small">{realtimeIssueLabels(row).join(';')}</Typography.Text>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="vp-map-customer-card">
|
||||
<div className="vp-map-card-title">车辆列表</div>
|
||||
{mapServiceRows.length === 0 ? (
|
||||
<Typography.Text type="tertiary">暂无可定位车辆。</Typography.Text>
|
||||
) : mapServiceRows.map((row) => (
|
||||
<button
|
||||
key={`${row.vin}-${row.primaryProtocol}-map-list`}
|
||||
type="button"
|
||||
className={`vp-map-vehicle-row ${selectedMapRow?.vin === row.vin ? 'vp-map-vehicle-row-selected' : ''}`}
|
||||
onClick={() => selectRealtimeRow(row)}
|
||||
>
|
||||
<span>{row.plate || row.vin}</span>
|
||||
<Tag color={dataFreshness(row).color}>{dataFreshness(row).label}</Tag>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vp-page">
|
||||
|
||||
@@ -778,6 +778,195 @@ button.vp-realtime-command-item:focus-visible {
|
||||
height: 380px;
|
||||
}
|
||||
|
||||
.vp-map-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.vp-map-commandbar {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: var(--vp-surface);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vp-map-refresh-actions {
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vp-map-kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-map-kpi-item {
|
||||
min-height: 96px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vp-map-kpi-item:hover,
|
||||
.vp-map-kpi-item:focus-visible {
|
||||
border-color: rgba(22, 100, 255, 0.42);
|
||||
background: #f5f9ff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vp-map-kpi-item strong {
|
||||
color: var(--vp-text);
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vp-map-kpi-item span {
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.vp-map-workspace {
|
||||
min-height: 680px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.vp-map-canvas-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: var(--vp-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vp-map-canvas-toolbar {
|
||||
min-height: 54px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--vp-border);
|
||||
background: #fbfcff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vp-map-customer-canvas {
|
||||
height: 626px;
|
||||
}
|
||||
|
||||
.vp-map-customer-side {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-map-customer-card {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: var(--vp-surface);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-map-selected-card {
|
||||
border-color: rgba(22, 100, 255, 0.28);
|
||||
background: #f5f9ff;
|
||||
}
|
||||
|
||||
.vp-map-card-title {
|
||||
color: var(--vp-text);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vp-map-selected-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 7px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.vp-map-selected-metrics span {
|
||||
color: var(--vp-text-muted);
|
||||
}
|
||||
|
||||
.vp-map-selected-metrics strong {
|
||||
color: var(--vp-text);
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.vp-map-attention-row,
|
||||
.vp-map-vehicle-row {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius-sm);
|
||||
background: #fbfcff;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vp-map-attention-row {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vp-map-attention-row > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vp-map-vehicle-row {
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vp-map-vehicle-row span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--vp-text);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vp-map-attention-row:hover,
|
||||
.vp-map-attention-row:focus-visible,
|
||||
.vp-map-vehicle-row:hover,
|
||||
.vp-map-vehicle-row:focus-visible,
|
||||
.vp-map-vehicle-row-selected {
|
||||
border-color: rgba(22, 100, 255, 0.5);
|
||||
background: #f5f9ff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vp-map-dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
@@ -2577,6 +2766,8 @@ button.vp-realtime-command-item:focus-visible {
|
||||
.vp-realtime-impact-board,
|
||||
.vp-realtime-impact-grid,
|
||||
.vp-source-coverage-grid,
|
||||
.vp-map-kpi-strip,
|
||||
.vp-map-workspace,
|
||||
.vp-runbook-grid,
|
||||
.vp-workbench-grid,
|
||||
.vp-focus-service,
|
||||
@@ -2624,6 +2815,16 @@ button.vp-realtime-command-item:focus-visible {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vp-map-commandbar,
|
||||
.vp-map-canvas-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vp-map-customer-canvas {
|
||||
height: 460px;
|
||||
}
|
||||
|
||||
.vp-stat-closure-action {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -191,8 +191,9 @@ test('exposes AMap operations shortcuts when map key is configured', async () =>
|
||||
expect(await screen.findByText((content) => content.includes('platform-20260704153357'))).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '顶部地图态势' }));
|
||||
expect(window.location.hash).toBe('#/map');
|
||||
expect(await screen.findByRole('heading', { name: '实时地图' })).toBeInTheDocument();
|
||||
expect(screen.getByText('地图车辆队列')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: '车辆实时地图' })).toBeInTheDocument();
|
||||
expect(screen.getByText('关注车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('车辆列表')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '顶部实时监控' }));
|
||||
expect(window.location.hash).toBe('#/realtime');
|
||||
fireEvent.click(screen.getByRole('button', { name: '顶部轨迹回放' }));
|
||||
@@ -8968,11 +8969,13 @@ test('shows realtime freshness status for recently updated and stale vehicles',
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '实时地图' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: '车辆实时地图' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('数据新鲜').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('更新超时').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('超时车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 辆超过 5 分钟未更新。')).toBeInTheDocument();
|
||||
expect(screen.getByText('关注车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('选中车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('车辆列表')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 辆更新超时')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('exports current realtime vehicles as CSV', async () => {
|
||||
|
||||
Reference in New Issue
Block a user