feat(platform): customerize realtime map workspace

This commit is contained in:
lingniu
2026-07-05 01:16:45 +08:00
parent 83f033244c
commit 11710ec6fa
4 changed files with 362 additions and 6 deletions

View File

@@ -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} />,

View File

@@ -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">

View File

@@ -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;

View File

@@ -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 () => {