1158 lines
54 KiB
TypeScript
1158 lines
54 KiB
TypeScript
import { Button, Card, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||
import { IconCopy } from '@douyinfe/semi-icons';
|
||
import { useEffect, useState } from 'react';
|
||
import { api } from '../api/client';
|
||
import type { OpsHealth, VehicleRealtimeRow } from '../api/types';
|
||
import { DataEmpty } from '../components/DataEmpty';
|
||
import { PageHeader } from '../components/PageHeader';
|
||
import { SourceStatusTags } from '../components/SourceStatusTags';
|
||
import { StatusTag } from '../components/StatusTag';
|
||
import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
|
||
import { getAMapConfig, isAMapConfigured } from '../config/appConfig';
|
||
import { buildAppHash } from '../domain/appRoute';
|
||
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
||
|
||
function canOpenVehicle(vin?: string) {
|
||
const value = vin?.trim();
|
||
return Boolean(value && value !== 'unknown');
|
||
}
|
||
|
||
function vehicleServiceStatus(row: VehicleRealtimeRow) {
|
||
if (row.serviceStatus) {
|
||
return {
|
||
label: row.serviceStatus.title,
|
||
color: row.serviceStatus.severity === 'ok' ? 'green' as const : row.serviceStatus.severity === 'error' ? 'red' as const : 'orange' as const
|
||
};
|
||
}
|
||
if (row.onlineSourceCount <= 0) {
|
||
return { label: '车辆离线', color: 'red' as const };
|
||
}
|
||
if (row.onlineSourceCount < row.sourceCount) {
|
||
return { label: '来源不完整', color: 'orange' as const };
|
||
}
|
||
return { label: '服务正常', color: 'green' as const };
|
||
}
|
||
|
||
function sourceEvidenceText(row: VehicleRealtimeRow) {
|
||
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
|
||
}
|
||
|
||
function formatPercent(value: number) {
|
||
return Number.isFinite(value) ? `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%` : '0%';
|
||
}
|
||
|
||
function serviceStatusWeight(row: VehicleRealtimeRow) {
|
||
const severity = row.serviceStatus?.severity;
|
||
if (severity === 'error') return 4;
|
||
if (severity === 'warning') return 3;
|
||
if (row.onlineSourceCount <= 0) return 4;
|
||
if (row.onlineSourceCount < row.sourceCount) return 3;
|
||
return 1;
|
||
}
|
||
|
||
function sourceIssueTags(row: VehicleRealtimeRow) {
|
||
const tags = (row.sourceStatus ?? [])
|
||
.filter((source) => !source.online || !source.hasRealtime)
|
||
.map((source) => `${source.protocol} ${source.hasRealtime ? '离线' : '未接入'}`) ?? [];
|
||
if (tags.length > 0) return tags;
|
||
if (row.onlineSourceCount < row.sourceCount) return ['来源缺失'];
|
||
return [];
|
||
}
|
||
|
||
function hasSourceIssue(row: VehicleRealtimeRow) {
|
||
const severity = row.serviceStatus?.severity;
|
||
return severity === 'warning' || severity === 'error' || row.onlineSourceCount < row.sourceCount || sourceIssueTags(row).length > 0;
|
||
}
|
||
|
||
function isValidCoordinate(row: VehicleRealtimeRow) {
|
||
return Number.isFinite(row.longitude) && Number.isFinite(row.latitude) && row.longitude !== 0 && row.latitude !== 0;
|
||
}
|
||
|
||
function realtimeMapPointId(row: VehicleRealtimeRow, index = 0) {
|
||
return row.vin?.trim() || `${row.primaryProtocol || 'source'}-${index}`;
|
||
}
|
||
|
||
function formatRefreshTime(date: Date) {
|
||
return date.toLocaleTimeString('zh-CN', {
|
||
hour12: false,
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
});
|
||
}
|
||
|
||
function parseVehicleTime(value?: string) {
|
||
const normalized = value?.trim();
|
||
if (!normalized) {
|
||
return Number.NaN;
|
||
}
|
||
return new Date(normalized.replace(' ', 'T')).getTime();
|
||
}
|
||
|
||
function dataFreshness(row: VehicleRealtimeRow) {
|
||
const timestamp = parseVehicleTime(row.lastSeen);
|
||
if (!Number.isFinite(timestamp)) {
|
||
return {
|
||
label: '无时间',
|
||
color: 'grey' as const,
|
||
detail: '未上报最后时间',
|
||
stale: true
|
||
};
|
||
}
|
||
const ageSeconds = Math.floor((Date.now() - timestamp) / 1000);
|
||
if (ageSeconds <= 300) {
|
||
return {
|
||
label: '数据新鲜',
|
||
color: 'green' as const,
|
||
detail: ageSeconds <= 0 ? '刚刚更新' : `${Math.max(1, Math.ceil(ageSeconds / 60))} 分钟内更新`,
|
||
stale: false
|
||
};
|
||
}
|
||
if (ageSeconds < 3600) {
|
||
return {
|
||
label: '更新超时',
|
||
color: 'orange' as const,
|
||
detail: `${Math.ceil(ageSeconds / 60)} 分钟未更新`,
|
||
stale: true
|
||
};
|
||
}
|
||
if (ageSeconds < 86400) {
|
||
return {
|
||
label: '更新超时',
|
||
color: 'red' as const,
|
||
detail: `${Math.ceil(ageSeconds / 3600)} 小时未更新`,
|
||
stale: true
|
||
};
|
||
}
|
||
return {
|
||
label: '更新超时',
|
||
color: 'red' as const,
|
||
detail: `${Math.ceil(ageSeconds / 86400)} 天未更新`,
|
||
stale: true
|
||
};
|
||
}
|
||
|
||
function amapMarkerURL(row: VehicleRealtimeRow) {
|
||
const name = encodeURIComponent(row.plate || row.vin || '车辆位置');
|
||
return `https://uri.amap.com/marker?position=${row.longitude},${row.latitude}&name=${name}&src=lingniu-vehicle-platform`;
|
||
}
|
||
|
||
const onlineLabel: Record<string, string> = {
|
||
online: '在线',
|
||
offline: '离线'
|
||
};
|
||
|
||
const serviceStatusLabel: Record<string, string> = {
|
||
healthy: '服务正常',
|
||
degraded: '来源不完整',
|
||
offline: '车辆离线',
|
||
identity_required: '身份未绑定'
|
||
};
|
||
|
||
const realtimeExportColumns: CsvColumn<VehicleRealtimeRow>[] = [
|
||
{ title: 'VIN', value: (row) => row.vin },
|
||
{ title: '车牌', value: (row) => row.plate },
|
||
{ title: '手机号', value: (row) => row.phone },
|
||
{ title: 'OEM', value: (row) => row.oem },
|
||
{ title: '主来源', value: (row) => row.primaryProtocol },
|
||
{ title: '来源列表', value: (row) => row.protocols?.join('|') },
|
||
{ title: '在线', value: (row) => row.online ? '在线' : '离线' },
|
||
{ title: '车辆服务状态', value: (row) => vehicleServiceStatus(row).label },
|
||
{ title: '来源在线', value: (row) => sourceEvidenceText(row) },
|
||
{ title: '经度', value: (row) => row.longitude },
|
||
{ title: '纬度', value: (row) => row.latitude },
|
||
{ title: '速度km/h', value: (row) => row.speedKmh },
|
||
{ title: 'SOC%', value: (row) => row.socPercent },
|
||
{ title: '总里程km', value: (row) => row.totalMileageKm },
|
||
{ title: '最后时间', value: (row) => row.lastSeen },
|
||
{ title: '绑定状态', value: (row) => row.bindingStatus }
|
||
];
|
||
|
||
type RealtimeSourceCoverage = {
|
||
protocol: string;
|
||
total: number;
|
||
online: number;
|
||
located: number;
|
||
degraded: number;
|
||
stale: number;
|
||
};
|
||
|
||
function realtimeExportFileName(filters: Record<string, string>) {
|
||
const keyword = filters.keyword?.trim() || 'all';
|
||
const protocol = filters.protocol?.trim() || 'all-source';
|
||
const online = filters.online?.trim() || 'all-online';
|
||
return `realtime-vehicles-${keyword}-${protocol}-${online}.csv`;
|
||
}
|
||
|
||
function realtimeFilterSummary(filters: Record<string, string>) {
|
||
return [
|
||
filters.keyword ? `关键词:${filters.keyword}` : '',
|
||
filters.protocol ? `数据来源:${filters.protocol}` : '',
|
||
filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '',
|
||
filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : ''
|
||
].filter(Boolean);
|
||
}
|
||
|
||
function realtimeOperationsSummaryText({
|
||
filters,
|
||
rows,
|
||
total,
|
||
onlineCount,
|
||
locatedCount,
|
||
degradedCount,
|
||
sourceTypeCount,
|
||
amapConfigured,
|
||
sourceIssueRows
|
||
}: {
|
||
filters: Record<string, string>;
|
||
rows: VehicleRealtimeRow[];
|
||
total: number;
|
||
onlineCount: number;
|
||
locatedCount: number;
|
||
degradedCount: number;
|
||
sourceTypeCount: number;
|
||
amapConfigured: boolean;
|
||
sourceIssueRows: VehicleRealtimeRow[];
|
||
}) {
|
||
const issueLines = sourceIssueRows.length > 0
|
||
? sourceIssueRows.map((row, index) => {
|
||
const status = vehicleServiceStatus(row);
|
||
return `${index + 1}. ${row.plate || row.vin} / ${row.primaryProtocol || '-'} / ${status.label} / ${row.serviceStatus?.detail || sourceEvidenceText(row)}`;
|
||
}).join('\n')
|
||
: '暂无重点车辆';
|
||
return [
|
||
'【实时监控摘要】',
|
||
`当前筛选:${realtimeFilterSummary(filters).join(';') || '全部实时车辆'}`,
|
||
`车辆总数:${total.toLocaleString()},当前页:${rows.length.toLocaleString()}`,
|
||
`在线车辆:${onlineCount.toLocaleString()},定位有效:${locatedCount.toLocaleString()}`,
|
||
`降级服务:${degradedCount.toLocaleString()},来源类型:${sourceTypeCount.toLocaleString()}`,
|
||
`地图配置:${amapConfigured ? '已配置' : '未配置'}`,
|
||
'重点车辆:',
|
||
issueLines,
|
||
`实时页面:${window.location.origin}${window.location.pathname}${window.location.hash}`
|
||
].join('\n');
|
||
}
|
||
|
||
function appURL(hash: string) {
|
||
return `${window.location.origin}${window.location.pathname}${hash}`;
|
||
}
|
||
|
||
function rawFrameAPIURL(row: VehicleRealtimeRow, protocol: string) {
|
||
const params = new URLSearchParams({
|
||
protocol,
|
||
vin: row.vin || '',
|
||
limit: '20',
|
||
includeFields: 'true'
|
||
});
|
||
return `${window.location.origin}/api/history/raw-frames?${params.toString()}`;
|
||
}
|
||
|
||
function realtimeIssueLabels(row: VehicleRealtimeRow) {
|
||
const labels: string[] = [];
|
||
const freshness = dataFreshness(row);
|
||
if (freshness.stale) {
|
||
labels.push(freshness.detail);
|
||
}
|
||
if (!isValidCoordinate(row)) {
|
||
labels.push('坐标无效');
|
||
}
|
||
if (hasSourceIssue(row)) {
|
||
labels.push(row.serviceStatus?.detail || sourceIssueTags(row).join('、') || sourceEvidenceText(row));
|
||
}
|
||
return labels.length > 0 ? labels : ['暂无异常'];
|
||
}
|
||
|
||
function realtimeIssueAction(row: VehicleRealtimeRow) {
|
||
const issues = realtimeIssueLabels(row).join(';');
|
||
if (!isValidCoordinate(row)) {
|
||
return `核对${row.primaryProtocol || '实时来源'}定位字段解析和平台转发`;
|
||
}
|
||
if (dataFreshness(row).stale) {
|
||
return `确认${row.primaryProtocol || '实时来源'}是否持续上报,必要时检查接入链路`;
|
||
}
|
||
if (hasSourceIssue(row)) {
|
||
return '补齐离线来源,确认多来源实时状态一致';
|
||
}
|
||
return issues === '暂无异常' ? '持续观察' : '结合车辆服务详情继续排查';
|
||
}
|
||
|
||
function realtimeIssueChecklistText({
|
||
filters,
|
||
rows,
|
||
total
|
||
}: {
|
||
filters: Record<string, string>;
|
||
rows: VehicleRealtimeRow[];
|
||
total: number;
|
||
}) {
|
||
const issueRows = rows
|
||
.filter((row) => canOpenVehicle(row.vin) && (dataFreshness(row).stale || !isValidCoordinate(row) || hasSourceIssue(row)))
|
||
.sort((a, b) => {
|
||
const statusDelta = serviceStatusWeight(b) - serviceStatusWeight(a);
|
||
if (statusDelta !== 0) return statusDelta;
|
||
return Number(dataFreshness(b).stale) - Number(dataFreshness(a).stale);
|
||
});
|
||
const lines = [
|
||
'【实时异常处置清单】',
|
||
`当前筛选:${realtimeFilterSummary(filters).join(';') || '全部实时车辆'}`,
|
||
`异常车辆:${issueRows.length.toLocaleString()} / 当前页 ${rows.length.toLocaleString()} / 总计 ${total.toLocaleString()}`,
|
||
''
|
||
];
|
||
if (issueRows.length === 0) {
|
||
lines.push('当前页暂无实时异常车辆。');
|
||
}
|
||
issueRows.forEach((row, index) => {
|
||
const protocol = filters.protocol || row.primaryProtocol || '';
|
||
const status = vehicleServiceStatus(row);
|
||
const issues = realtimeIssueLabels(row).join(';');
|
||
lines.push(
|
||
`${index + 1}. ${row.plate || '-'} / ${row.vin} / ${protocol || '-'}`,
|
||
` 状态:${status.label};在线:${row.online ? '在线' : '离线'};问题:${issues}`,
|
||
` 位置:${isValidCoordinate(row) ? `${row.longitude},${row.latitude}` : '无有效坐标'};速度:${row.speedKmh ?? '-'} km/h;SOC:${row.socPercent ?? '-'}%`,
|
||
` 最后时间:${row.lastSeen || '-'};建议动作:${realtimeIssueAction(row)}`,
|
||
` 车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: row.vin, protocol }))}`,
|
||
` 实时监控:${appURL(buildAppHash({ page: 'realtime', keyword: row.vin, protocol }))}`,
|
||
` 轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: row.vin, protocol }))}`,
|
||
` 告警事件:${appURL(buildAppHash({ page: 'alert-events', keyword: row.vin, protocol }))}`
|
||
);
|
||
});
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function realtimeDutyHandoffText({
|
||
filters,
|
||
rows,
|
||
total,
|
||
onlineCount,
|
||
locatedCount,
|
||
degradedCount,
|
||
staleCount,
|
||
sourceTypeCount,
|
||
amapConfigured,
|
||
runtimeRelease
|
||
}: {
|
||
filters: Record<string, string>;
|
||
rows: VehicleRealtimeRow[];
|
||
total: number;
|
||
onlineCount: number;
|
||
locatedCount: number;
|
||
degradedCount: number;
|
||
staleCount: number;
|
||
sourceTypeCount: number;
|
||
amapConfigured: boolean;
|
||
runtimeRelease?: string;
|
||
}) {
|
||
const sourceCoverage = new Map<string, { total: number; online: number; realtime: number }>();
|
||
rows.forEach((row) => {
|
||
row.sourceStatus?.forEach((source) => {
|
||
const current = sourceCoverage.get(source.protocol) ?? { total: 0, online: 0, realtime: 0 };
|
||
current.total += 1;
|
||
current.online += source.online ? 1 : 0;
|
||
current.realtime += source.hasRealtime ? 1 : 0;
|
||
sourceCoverage.set(source.protocol, current);
|
||
});
|
||
});
|
||
const sourceLines = [...sourceCoverage.entries()]
|
||
.sort(([left], [right]) => left.localeCompare(right))
|
||
.map(([protocol, item]) => `${protocol}:在线 ${item.online}/${item.total},实时 ${item.realtime}/${item.total}`);
|
||
const sampleRows = rows
|
||
.filter((row) => canOpenVehicle(row.vin))
|
||
.sort((a, b) => {
|
||
const statusDelta = serviceStatusWeight(b) - serviceStatusWeight(a);
|
||
if (statusDelta !== 0) return statusDelta;
|
||
return Number(dataFreshness(b).stale) - Number(dataFreshness(a).stale);
|
||
})
|
||
.slice(0, 8);
|
||
const lines = [
|
||
'【实时值班交接包】',
|
||
`当前筛选:${realtimeFilterSummary(filters).join(';') || '全部实时车辆'}`,
|
||
`页面车辆:${rows.length.toLocaleString()} / 总计 ${total.toLocaleString()};版本:${runtimeRelease || '未标记'}`,
|
||
`在线:${onlineCount.toLocaleString()};离线:${(rows.length - onlineCount).toLocaleString()};定位有效:${locatedCount.toLocaleString()};降级:${degradedCount.toLocaleString()};超时:${staleCount.toLocaleString()};来源类型:${sourceTypeCount.toLocaleString()}`,
|
||
`地图:${amapConfigured ? '高德已配置' : '高德未配置'}`,
|
||
`来源覆盖:${sourceLines.length > 0 ? sourceLines.join(';') : '当前页暂无来源明细'}`,
|
||
`值班入口:${appURL(buildAppHash({ page: 'realtime', protocol: filters.protocol, filters }))}`,
|
||
''
|
||
];
|
||
if (sampleRows.length === 0) {
|
||
lines.push('当前页暂无可交接 VIN 样本。');
|
||
return lines.join('\n');
|
||
}
|
||
lines.push('车辆样本:');
|
||
sampleRows.forEach((row, index) => {
|
||
const protocol = filters.protocol || row.primaryProtocol || '';
|
||
const status = vehicleServiceStatus(row);
|
||
const freshness = dataFreshness(row);
|
||
lines.push(
|
||
`${index + 1}. ${row.plate || '-'} / ${row.vin} / ${protocol || '-'} / ${status.label}`,
|
||
` 最后上报:${row.lastSeen || '-'};新鲜度:${freshness.label};位置:${isValidCoordinate(row) ? `${row.longitude},${row.latitude}` : '无有效坐标'};速度:${row.speedKmh ?? '-'} km/h;SOC:${row.socPercent ?? '-'}%`,
|
||
` 来源:${sourceEvidenceText(row)};问题:${realtimeIssueLabels(row).join(';')}`,
|
||
` 车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: row.vin, protocol }))}`,
|
||
` 实时监控:${appURL(buildAppHash({ page: 'realtime', keyword: row.vin, protocol }))}`,
|
||
` 轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: row.vin, protocol }))}`,
|
||
` 里程统计:${appURL(buildAppHash({ page: 'mileage', keyword: row.vin, protocol }))}`,
|
||
` 历史RAW:${protocol ? rawFrameAPIURL(row, protocol) : '-'}`
|
||
);
|
||
});
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function realtimeImpactReportText({
|
||
filters,
|
||
rows,
|
||
total,
|
||
onlineCount,
|
||
locatedCount,
|
||
degradedCount,
|
||
staleCount,
|
||
pageCoverageRate,
|
||
amapConfigured,
|
||
runtimeRelease
|
||
}: {
|
||
filters: Record<string, string>;
|
||
rows: VehicleRealtimeRow[];
|
||
total: number;
|
||
onlineCount: number;
|
||
locatedCount: number;
|
||
degradedCount: number;
|
||
staleCount: number;
|
||
pageCoverageRate: number;
|
||
amapConfigured: boolean;
|
||
runtimeRelease?: string;
|
||
}) {
|
||
const offlineCount = Math.max(0, rows.length - onlineCount);
|
||
const impactLevel = degradedCount > 0 || staleCount > 0 || offlineCount > 0 ? '需要处置' : '实时稳定';
|
||
return [
|
||
'【实时运营影响】',
|
||
`当前筛选:${realtimeFilterSummary(filters).join(';') || '全部实时车辆'}`,
|
||
`运行版本:${runtimeRelease || '未标记'}`,
|
||
`影响等级:${impactLevel}`,
|
||
`车辆范围:当前页 ${rows.length.toLocaleString()} / 总计 ${total.toLocaleString()},覆盖率 ${formatPercent(pageCoverageRate)}`,
|
||
`在线状态:${onlineCount.toLocaleString()} 在线 / ${offlineCount.toLocaleString()} 离线`,
|
||
`定位影响:${locatedCount.toLocaleString()} 辆有有效坐标`,
|
||
`服务影响:${degradedCount.toLocaleString()} 辆降级 / ${staleCount.toLocaleString()} 辆超时`,
|
||
`地图能力:${amapConfigured ? '高德地图可用' : '高德地图未配置,使用坐标预览'}`,
|
||
`建议动作:${impactLevel === '实时稳定' ? '保持监控并关注告警队列' : '优先处理离线、超时和来源不完整车辆,并回到轨迹/RAW证据复核'}`,
|
||
`实时监控:${appURL(buildAppHash({ page: 'realtime', protocol: filters.protocol, filters }))}`
|
||
].join('\n');
|
||
}
|
||
|
||
function buildRealtimeSourceCoverage(rows: VehicleRealtimeRow[]) {
|
||
const sourceMap = new Map<string, RealtimeSourceCoverage>();
|
||
rows.forEach((row) => {
|
||
const protocols = row.sourceStatus?.length
|
||
? row.sourceStatus.map((source) => source.protocol)
|
||
: row.primaryProtocol ? [row.primaryProtocol] : [];
|
||
protocols.forEach((protocol) => {
|
||
const source = row.sourceStatus?.find((item) => item.protocol === protocol);
|
||
const current = sourceMap.get(protocol) ?? { protocol, total: 0, online: 0, located: 0, degraded: 0, stale: 0 };
|
||
current.total += 1;
|
||
current.online += (source ? source.online : row.online) ? 1 : 0;
|
||
current.located += isValidCoordinate(row) ? 1 : 0;
|
||
current.degraded += (!source || !source.online || !source.hasRealtime || row.onlineSourceCount < row.sourceCount) ? 1 : 0;
|
||
current.stale += dataFreshness(row).stale ? 1 : 0;
|
||
sourceMap.set(protocol, current);
|
||
});
|
||
});
|
||
return [...sourceMap.values()].sort((left, right) => right.total - left.total || left.protocol.localeCompare(right.protocol));
|
||
}
|
||
|
||
async function copyText(value: string, label: string) {
|
||
const text = value.trim();
|
||
if (!text) {
|
||
Toast.warning(`${label}为空`);
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
Toast.success(`已复制${label}`);
|
||
} catch {
|
||
Toast.error(`复制${label}失败`);
|
||
}
|
||
}
|
||
|
||
export function Realtime({
|
||
title = '实时监控',
|
||
description = '以车辆为主对象查看最新位置、在线来源、核心实时数据和地图作业状态',
|
||
onOpenVehicle,
|
||
onOpenHistory,
|
||
onOpenQuality,
|
||
onFiltersChange,
|
||
initialFilters = {}
|
||
}: {
|
||
title?: string;
|
||
description?: string;
|
||
onOpenVehicle: (vin: string, protocol?: string) => void;
|
||
onOpenHistory?: (vin: string, protocol?: string) => void;
|
||
onOpenQuality?: (filters: Record<string, string>) => void;
|
||
onFiltersChange?: (filters: Record<string, string>) => void;
|
||
initialFilters?: Record<string, string>;
|
||
}) {
|
||
const [rows, setRows] = useState<VehicleRealtimeRow[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 });
|
||
const [opsHealth, setOpsHealth] = useState<OpsHealth | null>(null);
|
||
const [selectedMapPointId, setSelectedMapPointId] = useState('');
|
||
const [lastRefreshAt, setLastRefreshAt] = useState('');
|
||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||
const refreshIntervalSeconds = 15;
|
||
const amapConfig = getAMapConfig();
|
||
const runtime = opsHealth?.runtime;
|
||
const amapConfigured = runtime?.amapWebJsConfigured ?? isAMapConfigured(amapConfig);
|
||
const amapSecurityServiceHost = runtime?.amapSecurityServiceHost || amapConfig.securityServiceHost;
|
||
const amapSecurityProxyEnabled = runtime?.amapSecurityProxyEnabled ?? Boolean(amapSecurityServiceHost);
|
||
const amapSecurityCodeExposed = runtime?.amapSecurityCodeExposed ?? Boolean(amapConfig.securityJsCode && !amapSecurityServiceHost);
|
||
|
||
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||
setLoading(true);
|
||
const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) });
|
||
if (values?.keyword) params.set('keyword', values.keyword);
|
||
if (values?.protocol) params.set('protocol', values.protocol);
|
||
if (values?.online) params.set('online', values.online);
|
||
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
|
||
return api.vehicleRealtime(params)
|
||
.then((nextPage) => {
|
||
setRows(nextPage.items);
|
||
setPagination({ currentPage: page, pageSize, total: nextPage.total });
|
||
setLastRefreshAt(formatRefreshTime(new Date()));
|
||
})
|
||
.catch((error: Error) => Toast.error(error.message))
|
||
.finally(() => setLoading(false));
|
||
};
|
||
|
||
useEffect(() => {
|
||
setFilters(initialFilters);
|
||
load(initialFilters, 1, pagination.pageSize);
|
||
api.opsHealth()
|
||
.then(setOpsHealth)
|
||
.catch(() => setOpsHealth(null));
|
||
}, [JSON.stringify(initialFilters)]);
|
||
|
||
useEffect(() => {
|
||
if (!autoRefresh) {
|
||
return undefined;
|
||
}
|
||
const timer = window.setInterval(() => {
|
||
load(filters, pagination.currentPage, pagination.pageSize);
|
||
}, refreshIntervalSeconds * 1000);
|
||
return () => window.clearInterval(timer);
|
||
}, [autoRefresh, JSON.stringify(filters), pagination.currentPage, pagination.pageSize]);
|
||
|
||
const applyFilters = (nextFilters: Record<string, string>) => {
|
||
setFilters(nextFilters);
|
||
onFiltersChange?.(nextFilters);
|
||
load(nextFilters, 1, pagination.pageSize);
|
||
};
|
||
const openQualityEvidence = (row: VehicleRealtimeRow) => {
|
||
if (!canOpenVehicle(row.vin)) return;
|
||
onOpenQuality?.({
|
||
keyword: row.vin,
|
||
protocol: filters.protocol || row.primaryProtocol || ''
|
||
});
|
||
};
|
||
const exportRealtime = () => {
|
||
if (rows.length === 0) {
|
||
Toast.warning('当前没有可导出的实时车辆');
|
||
return;
|
||
}
|
||
downloadCsv(realtimeExportFileName(filters), buildCsv(realtimeExportColumns, rows));
|
||
Toast.success(`已导出 ${rows.length} 条实时车辆`);
|
||
};
|
||
const filterSummary = realtimeFilterSummary(filters);
|
||
const onlineCount = rows.filter((row) => row.online).length;
|
||
const locatedCount = rows.filter(isValidCoordinate).length;
|
||
const degradedCount = rows.filter((row) => row.onlineSourceCount < row.sourceCount).length;
|
||
const staleCount = rows.filter((row) => dataFreshness(row).stale).length;
|
||
const onlineRate = rows.length > 0 ? (onlineCount / rows.length) * 100 : 0;
|
||
const locatedRate = rows.length > 0 ? (locatedCount / rows.length) * 100 : 0;
|
||
const degradedRate = rows.length > 0 ? (degradedCount / rows.length) * 100 : 0;
|
||
const pageCoverageRate = pagination.total > 0 ? (rows.length / pagination.total) * 100 : 0;
|
||
const primaryProtocols = new Set(rows.map((row) => row.primaryProtocol).filter(Boolean));
|
||
const sourceCoverageRows = buildRealtimeSourceCoverage(rows);
|
||
const sourceIssueRows = rows
|
||
.filter((row) => canOpenVehicle(row.vin) && hasSourceIssue(row))
|
||
.sort((a, b) => {
|
||
const statusDelta = serviceStatusWeight(b) - serviceStatusWeight(a);
|
||
if (statusDelta !== 0) return statusDelta;
|
||
return String(b.lastSeen ?? '').localeCompare(String(a.lastSeen ?? ''));
|
||
})
|
||
.slice(0, 4);
|
||
const mapServiceRows = rows
|
||
.filter((row) => isValidCoordinate(row) && canOpenVehicle(row.vin))
|
||
.sort((a, b) => {
|
||
const statusDelta = serviceStatusWeight(b) - serviceStatusWeight(a);
|
||
if (statusDelta !== 0) return statusDelta;
|
||
return String(b.lastSeen ?? '').localeCompare(String(a.lastSeen ?? ''));
|
||
})
|
||
.slice(0, 5);
|
||
const defaultMapRow = mapServiceRows[0] ?? rows.find((row) => isValidCoordinate(row) && canOpenVehicle(row.vin));
|
||
const selectedMapRow = rows.find((row, index) => realtimeMapPointId(row, index) === selectedMapPointId) ?? defaultMapRow;
|
||
const selectedMapPointKey = selectedMapRow ? realtimeMapPointId(selectedMapRow, rows.indexOf(selectedMapRow)) : selectedMapPointId;
|
||
const mapPoints: VehicleMapPoint[] = rows.map((row, index) => ({
|
||
id: realtimeMapPointId(row, index),
|
||
label: row.plate || row.vin || 'unknown',
|
||
longitude: row.longitude,
|
||
latitude: row.latitude,
|
||
online: row.online,
|
||
title: `${row.plate || row.vin || '-'} ${vehicleServiceStatus(row).label} ${row.primaryProtocol || ''} ${row.lastSeen || ''}`
|
||
}));
|
||
const mapIntegrationRows = [
|
||
{
|
||
label: 'Web JS Key',
|
||
value: amapConfigured ? '已配置' : '未配置',
|
||
color: amapConfigured ? 'green' as const : 'orange' as const,
|
||
detail: amapConfigured ? '前端可加载高德 Web JS API。' : '缺少公开 Key,地图将使用坐标预览。'
|
||
},
|
||
{
|
||
label: '服务端 API Key',
|
||
value: runtime?.amapApiConfigured ? '已配置' : '未配置',
|
||
color: runtime?.amapApiConfigured ? 'green' as const : 'orange' as const,
|
||
detail: runtime?.amapApiConfigured ? '后端可支持地理编码、路线、围栏等服务端地图能力。' : '服务端地图能力未配置,后续地理服务会降级。'
|
||
},
|
||
{
|
||
label: '安全代理',
|
||
value: amapSecurityServiceHost || '未启用',
|
||
color: amapSecurityProxyEnabled ? 'green' as const : 'grey' as const,
|
||
detail: amapSecurityProxyEnabled ? '安全密钥由服务端追加,不下发到浏览器。' : '未配置代理时会退回前端安全码模式。'
|
||
},
|
||
{
|
||
label: '安全码',
|
||
value: amapSecurityCodeExposed ? '已暴露' : '未暴露',
|
||
color: amapSecurityCodeExposed ? 'red' as const : 'green' as const,
|
||
detail: amapSecurityCodeExposed ? '当前安全码会下发到浏览器,只建议调试使用。' : '生产安全码保持在服务端环境变量中。'
|
||
},
|
||
{
|
||
label: '当前版本',
|
||
value: runtime?.platformRelease || '未标记',
|
||
color: runtime?.platformRelease ? 'blue' as const : 'grey' as const,
|
||
detail: runtime?.platformRelease ? '当前 ECS 正在运行的车辆中台 release。' : '未注入 PLATFORM_RELEASE,部署追踪会降级。'
|
||
},
|
||
{
|
||
label: '定位覆盖',
|
||
value: `${locatedCount.toLocaleString()} / ${rows.length.toLocaleString()}`,
|
||
color: locatedCount > 0 ? 'blue' as const : 'orange' as const,
|
||
detail: '当前页有效经纬度车辆数。'
|
||
},
|
||
{
|
||
label: '高德 URI',
|
||
value: '坐标跳转',
|
||
color: 'blue' as const,
|
||
detail: '车辆队列可直接打开高德坐标,轨迹页可打开线路。'
|
||
}
|
||
];
|
||
const copyRealtimeSummary = () => copyText(realtimeOperationsSummaryText({
|
||
filters,
|
||
rows,
|
||
total: pagination.total,
|
||
onlineCount,
|
||
locatedCount,
|
||
degradedCount,
|
||
sourceTypeCount: primaryProtocols.size,
|
||
amapConfigured,
|
||
sourceIssueRows
|
||
}), '实时摘要');
|
||
const copyRealtimeIssueChecklist = () => copyText(realtimeIssueChecklistText({ filters, rows, total: pagination.total }), '实时异常处置清单');
|
||
const copyRealtimeDutyHandoff = () => copyText(realtimeDutyHandoffText({
|
||
filters,
|
||
rows,
|
||
total: pagination.total,
|
||
onlineCount,
|
||
locatedCount,
|
||
degradedCount,
|
||
staleCount,
|
||
sourceTypeCount: primaryProtocols.size,
|
||
amapConfigured,
|
||
runtimeRelease: runtime?.platformRelease
|
||
}), '实时值班交接包');
|
||
const realtimeImpactLevel = degradedCount > 0 || staleCount > 0 || rows.length - onlineCount > 0 ? '需要处置' : '实时稳定';
|
||
const realtimeImpactColor = realtimeImpactLevel === '实时稳定' ? 'green' as const : degradedCount > 0 || staleCount > 0 ? 'orange' as const : 'blue' as const;
|
||
const realtimeImpactItems = [
|
||
{
|
||
label: '车辆范围',
|
||
value: `${rows.length.toLocaleString()} / ${pagination.total.toLocaleString()}`,
|
||
detail: `当前页覆盖 ${formatPercent(pageCoverageRate)}`,
|
||
color: pageCoverageRate >= 100 ? 'green' as const : 'grey' as const
|
||
},
|
||
{
|
||
label: '在线影响',
|
||
value: `${onlineCount.toLocaleString()} 在线`,
|
||
detail: `${(rows.length - onlineCount).toLocaleString()} 辆离线`,
|
||
color: rows.length - onlineCount > 0 ? 'orange' as const : 'green' as const
|
||
},
|
||
{
|
||
label: '定位影响',
|
||
value: `${locatedCount.toLocaleString()} 辆`,
|
||
detail: `有效定位率 ${formatPercent(locatedRate)}`,
|
||
color: locatedCount > 0 ? 'blue' as const : 'orange' as const
|
||
},
|
||
{
|
||
label: '服务影响',
|
||
value: `${degradedCount.toLocaleString()} 降级`,
|
||
detail: `${staleCount.toLocaleString()} 辆更新超时`,
|
||
color: degradedCount > 0 || staleCount > 0 ? 'orange' as const : 'green' as const
|
||
},
|
||
{
|
||
label: '地图能力',
|
||
value: amapConfigured ? '可用' : '坐标预览',
|
||
detail: amapConfigured ? '高德地图配置就绪' : '高德未配置,页面降级展示坐标。',
|
||
color: amapConfigured ? 'green' as const : 'orange' as const
|
||
}
|
||
];
|
||
const copyRealtimeImpact = () => copyText(realtimeImpactReportText({
|
||
filters,
|
||
rows,
|
||
total: pagination.total,
|
||
onlineCount,
|
||
locatedCount,
|
||
degradedCount,
|
||
staleCount,
|
||
pageCoverageRate,
|
||
amapConfigured,
|
||
runtimeRelease: runtime?.platformRelease
|
||
}), '实时运营影响');
|
||
const selectRealtimeRow = (row: VehicleRealtimeRow) => {
|
||
const index = rows.indexOf(row);
|
||
if (index < 0) return;
|
||
setSelectedMapPointId(realtimeMapPointId(row, index));
|
||
};
|
||
const selectRealtimeMapPoint = (point: VehicleMapPoint) => {
|
||
setSelectedMapPointId(point.id);
|
||
};
|
||
|
||
return (
|
||
<div className="vp-page">
|
||
<PageHeader title={title} description={description} />
|
||
<Card bordered>
|
||
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
||
const nextFilters = values as Record<string, string>;
|
||
applyFilters(nextFilters);
|
||
}} style={{ marginBottom: 12 }}>
|
||
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 260 }} />
|
||
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
|
||
<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>
|
||
<Form.Select field="serviceStatus" label="服务状态" placeholder="全部" style={{ width: 150 }}>
|
||
<Select.Option value="healthy">服务正常</Select.Option>
|
||
<Select.Option value="degraded">来源不完整</Select.Option>
|
||
<Select.Option value="offline">车辆离线</Select.Option>
|
||
<Select.Option value="identity_required">身份未绑定</Select.Option>
|
||
</Form.Select>
|
||
<Space>
|
||
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
||
<Button onClick={() => {
|
||
applyFilters({});
|
||
}}>重置</Button>
|
||
</Space>
|
||
</Form>
|
||
<div className="vp-table-toolbar">
|
||
<Space wrap>
|
||
<Tag color={autoRefresh ? 'green' : 'grey'}>自动刷新 {refreshIntervalSeconds}秒</Tag>
|
||
<Typography.Text type="secondary">最后刷新:{lastRefreshAt || '等待首次刷新'}</Typography.Text>
|
||
<Button size="small" theme="light" aria-label="刷新实时数据" loading={loading} onClick={() => load(filters, pagination.currentPage, pagination.pageSize)}>
|
||
刷新实时数据
|
||
</Button>
|
||
<Button size="small" theme="light" aria-label={autoRefresh ? '暂停自动刷新' : '开启自动刷新'} onClick={() => setAutoRefresh((value) => !value)}>
|
||
{autoRefresh ? '暂停自动刷新' : '开启自动刷新'}
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
{filterSummary.length > 0 ? (
|
||
<Card bordered title="当前实时筛选" style={{ marginBottom: 12 }}>
|
||
<Space wrap>
|
||
{filterSummary.map((item) => (
|
||
<Tag key={item} color="blue">{item}</Tag>
|
||
))}
|
||
<Button size="small" onClick={() => applyFilters({})}>清空筛选</Button>
|
||
</Space>
|
||
</Card>
|
||
) : null}
|
||
<div className="vp-realtime-command-board">
|
||
<div>
|
||
<div className="vp-current-service-title">实时作业入口</div>
|
||
<div className="vp-realtime-command-grid">
|
||
{[
|
||
{ label: '在线车辆', value: onlineCount, color: 'green' as const, action: () => applyFilters({ ...filters, online: 'online' }) },
|
||
{ label: '离线车辆', value: rows.length - onlineCount, color: rows.length - onlineCount > 0 ? 'orange' as const : 'green' as const, action: () => applyFilters({ ...filters, online: 'offline' }) },
|
||
{ label: '定位有效', value: locatedCount, color: locatedCount > 0 ? 'blue' as const : 'orange' as const },
|
||
{ label: '降级服务', value: degradedCount, color: degradedCount > 0 ? 'orange' as const : 'green' as const, action: () => applyFilters({ ...filters, serviceStatus: 'degraded' }) },
|
||
{ label: '更新超时', value: staleCount, color: staleCount > 0 ? 'red' as const : 'green' as const },
|
||
{ label: '来源类型', value: primaryProtocols.size, color: 'blue' as const }
|
||
].map((item) => (
|
||
item.action ? (
|
||
<button
|
||
key={item.label}
|
||
className="vp-realtime-command-item"
|
||
type="button"
|
||
aria-label={`实时筛选 ${item.label} ${item.value.toLocaleString()}`}
|
||
onClick={item.action}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<span>{item.value.toLocaleString()}</span>
|
||
</button>
|
||
) : (
|
||
<div key={item.label} className="vp-realtime-command-item">
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<span>{item.value.toLocaleString()}</span>
|
||
</div>
|
||
)
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="vp-current-service-title">建议处置</div>
|
||
<div className="vp-current-action-list">
|
||
{degradedCount > 0 ? (
|
||
<Button size="small" theme="light" type="warning" onClick={() => applyFilters({ ...filters, serviceStatus: 'degraded' })}>
|
||
排查降级服务 {degradedCount.toLocaleString()}
|
||
</Button>
|
||
) : <Tag color="green">服务状态稳定</Tag>}
|
||
{staleCount > 0 ? <Tag color="red">核对超时车辆 {staleCount.toLocaleString()}</Tag> : <Tag color="green">实时新鲜</Tag>}
|
||
{sourceIssueRows.length > 0 && onOpenQuality ? (
|
||
<Button size="small" theme="light" type="warning" onClick={() => onOpenQuality({ serviceStatus: 'degraded' })}>
|
||
查看质量告警 {sourceIssueRows.length.toLocaleString()}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="vp-source-coverage-board">
|
||
<div className="vp-current-service-title">协议地图覆盖</div>
|
||
<div className="vp-source-coverage-grid">
|
||
{sourceCoverageRows.length === 0 ? (
|
||
<Typography.Text type="tertiary">当前页暂无协议来源。</Typography.Text>
|
||
) : sourceCoverageRows.map((item) => (
|
||
<button
|
||
key={item.protocol}
|
||
type="button"
|
||
className="vp-source-coverage-item"
|
||
aria-label={`筛选协议 ${item.protocol}`}
|
||
onClick={() => applyFilters({ ...filters, protocol: item.protocol })}
|
||
>
|
||
<div className="vp-source-coverage-item-head">
|
||
<Tag color={item.degraded > 0 ? 'orange' : 'green'}>{item.protocol}</Tag>
|
||
<span>{item.total.toLocaleString()} 辆</span>
|
||
</div>
|
||
<div className="vp-source-coverage-bars">
|
||
<span>在线 {item.online.toLocaleString()}</span>
|
||
<span>定位 {item.located.toLocaleString()}</span>
|
||
<span>降级 {item.degraded.toLocaleString()}</span>
|
||
<span>超时 {item.stale.toLocaleString()}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="vp-monitor-layout">
|
||
<div className="vp-monitor-map">
|
||
<div className="vp-monitor-map-header">
|
||
<Space wrap>
|
||
<Tag color={amapConfigured ? 'green' : 'orange'}>
|
||
{amapConfigured ? '高德地图配置就绪' : '高德地图待配置'}
|
||
</Tag>
|
||
<Tag color="blue">{locatedCount.toLocaleString()} 辆有定位</Tag>
|
||
<Tag color="green">{onlineCount.toLocaleString()} 辆在线</Tag>
|
||
<Button size="small" theme="light" icon={<IconCopy />} aria-label="复制实时摘要" onClick={copyRealtimeSummary}>复制实时摘要</Button>
|
||
<Button size="small" theme="light" icon={<IconCopy />} aria-label="复制实时异常处置清单" onClick={copyRealtimeIssueChecklist}>复制异常处置清单</Button>
|
||
<Button size="small" theme="light" icon={<IconCopy />} aria-label="复制实时值班交接包" onClick={copyRealtimeDutyHandoff}>复制值班交接包</Button>
|
||
</Space>
|
||
</div>
|
||
<VehicleMap
|
||
points={mapPoints}
|
||
maxFallbackPoints={80}
|
||
selectedId={selectedMapPointKey}
|
||
onPointSelect={selectRealtimeMapPoint}
|
||
fallbackLabel="高德地图未配置,显示实时坐标预览"
|
||
/>
|
||
</div>
|
||
<div className="vp-monitor-side">
|
||
{[
|
||
{ label: '当前车辆', value: pagination.total.toLocaleString(), color: 'blue' as const },
|
||
{ label: '在线车辆', value: onlineCount.toLocaleString(), color: 'green' as const },
|
||
{ label: '定位有效', value: locatedCount.toLocaleString(), color: 'green' as const },
|
||
{ label: '超时车辆', value: staleCount.toLocaleString(), color: staleCount > 0 ? 'orange' as const : 'green' as const },
|
||
{ label: '降级服务', value: degradedCount.toLocaleString(), color: degradedCount > 0 ? 'orange' as const : 'green' as const },
|
||
{ label: '来源类型', value: primaryProtocols.size.toLocaleString(), color: 'blue' as const }
|
||
].map((item) => (
|
||
<div key={item.label} className="vp-monitor-metric">
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<div className="vp-monitor-metric-value">{item.value}</div>
|
||
</div>
|
||
))}
|
||
<Typography.Text type="secondary">
|
||
高德 Web JS Key 和安全密钥通过运行环境配置,前端只读取公开 Key;安全密钥后续走服务端代理,避免明文下发。
|
||
</Typography.Text>
|
||
{selectedMapRow ? (
|
||
<div className="vp-selected-vehicle-panel">
|
||
<div className="vp-map-service-queue-title">当前选中车辆</div>
|
||
<Space wrap>
|
||
<Tag color={vehicleServiceStatus(selectedMapRow).color}>{vehicleServiceStatus(selectedMapRow).label}</Tag>
|
||
<Tag color={dataFreshness(selectedMapRow).color}>{dataFreshness(selectedMapRow).label}</Tag>
|
||
<Tag color={selectedMapRow.online ? 'green' : 'orange'}>{selectedMapRow.online ? '在线' : '离线'}</Tag>
|
||
<Tag color="blue">{selectedMapRow.primaryProtocol || '未知来源'}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={6} style={{ margin: '8px 0 0' }}>
|
||
{selectedMapRow.plate || selectedMapRow.vin}
|
||
</Typography.Title>
|
||
<Typography.Text type="tertiary">{selectedMapRow.vin}</Typography.Text>
|
||
<div className="vp-selected-vehicle-grid">
|
||
<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>{dataFreshness(selectedMapRow).detail}</strong>
|
||
<span>最后时间</span><strong>{selectedMapRow.lastSeen || '-'}</strong>
|
||
</div>
|
||
<Space spacing={6} wrap>
|
||
<Button size="small" 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-integration-panel">
|
||
<div className="vp-map-service-queue-title">地图接入状态</div>
|
||
{mapIntegrationRows.map((item) => (
|
||
<div key={item.label} className="vp-map-integration-row">
|
||
<div>
|
||
<Typography.Text strong>{item.label}</Typography.Text>
|
||
<Typography.Text type="tertiary" size="small">{item.detail}</Typography.Text>
|
||
</div>
|
||
<Tag color={item.color}>{item.value}</Tag>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="vp-source-consistency-panel">
|
||
<div className="vp-map-service-queue-title">多来源一致性检查</div>
|
||
{sourceIssueRows.length === 0 ? (
|
||
<Typography.Text type="tertiary">当前页车辆来源一致</Typography.Text>
|
||
) : (
|
||
sourceIssueRows.map((row) => {
|
||
const status = vehicleServiceStatus(row);
|
||
return (
|
||
<div key={`${row.vin}-${row.primaryProtocol}-consistency`} className="vp-map-service-item">
|
||
<div className="vp-map-service-item-main">
|
||
<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>
|
||
</div>
|
||
<Space spacing={4} wrap>
|
||
<Tag color="blue">{sourceEvidenceText(row)}</Tag>
|
||
<Tag color={dataFreshness(row).color}>{dataFreshness(row).label}</Tag>
|
||
{sourceIssueTags(row).map((item) => (
|
||
<Tag key={item} color="orange">一致性:{item}</Tag>
|
||
))}
|
||
</Space>
|
||
<Typography.Text type="secondary" size="small">
|
||
一致性诊断:{row.serviceStatus?.detail || sourceEvidenceText(row)}
|
||
</Typography.Text>
|
||
<div className="vp-map-service-item-footer">
|
||
<Typography.Text type="tertiary" size="small">{row.lastSeen || '-'}</Typography.Text>
|
||
<Space spacing={4} wrap>
|
||
<Button size="small" disabled={!onOpenQuality} onClick={() => openQualityEvidence(row)}>检查质量</Button>
|
||
<Button size="small" onClick={() => onOpenVehicle(row.vin, filters.protocol || row.primaryProtocol)}>查看服务</Button>
|
||
</Space>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
<div className="vp-map-service-queue">
|
||
<div className="vp-map-service-queue-title">地图车辆服务队列</div>
|
||
{mapServiceRows.length === 0 ? (
|
||
<Typography.Text type="tertiary">暂无可定位车辆</Typography.Text>
|
||
) : (
|
||
mapServiceRows.map((row) => {
|
||
const status = vehicleServiceStatus(row);
|
||
return (
|
||
<div
|
||
key={`${row.vin}-${row.primaryProtocol}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
className={`vp-map-service-item vp-map-service-item-button ${selectedMapRow?.vin === row.vin ? 'vp-map-service-item-selected' : ''}`}
|
||
onClick={() => selectRealtimeRow(row)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
selectRealtimeRow(row);
|
||
}
|
||
}}
|
||
>
|
||
<div className="vp-map-service-item-main">
|
||
<div>
|
||
<Typography.Text strong>{row.plate || row.vin}</Typography.Text>
|
||
<Typography.Text type="tertiary" size="small">{row.vin}</Typography.Text>
|
||
</div>
|
||
<Space spacing={4} wrap>
|
||
<Tag color={dataFreshness(row).color}>{dataFreshness(row).label}</Tag>
|
||
<Tag color={status.color}>{status.label}</Tag>
|
||
</Space>
|
||
</div>
|
||
<Typography.Text type="secondary" size="small">
|
||
{row.serviceStatus?.detail || sourceEvidenceText(row)}
|
||
</Typography.Text>
|
||
<Typography.Text type="tertiary" size="small">
|
||
新鲜度:{dataFreshness(row).detail}
|
||
</Typography.Text>
|
||
<div className="vp-map-service-item-footer">
|
||
<Typography.Text type="tertiary" size="small">{row.lastSeen || '-'}</Typography.Text>
|
||
<Space spacing={4} wrap>
|
||
<Button size="small" onClick={() => window.open(amapMarkerURL(row), '_blank', 'noopener,noreferrer')}>高德坐标</Button>
|
||
<Button size="small" onClick={() => onOpenHistory?.(row.vin, filters.protocol || row.primaryProtocol)}>轨迹回放</Button>
|
||
<Button size="small" disabled={!onOpenQuality} onClick={() => openQualityEvidence(row)}>质量问题</Button>
|
||
<Button size="small" onClick={() => onOpenVehicle(row.vin, filters.protocol || row.primaryProtocol)}>地图车辆服务</Button>
|
||
</Space>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Card
|
||
bordered
|
||
title={<Space><span>实时运营影响</span><Button size="small" icon={<IconCopy />} onClick={copyRealtimeImpact}>复制影响报告</Button></Space>}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<div className="vp-realtime-impact-board">
|
||
<div className="vp-realtime-impact-summary">
|
||
<Tag color={realtimeImpactColor}>{realtimeImpactLevel}</Tag>
|
||
<div className="vp-monitor-metric-value">{pagination.total.toLocaleString()} 辆</div>
|
||
<Typography.Text type="secondary">
|
||
当前页 {rows.length.toLocaleString()} 辆,在线 {onlineCount.toLocaleString()} 辆,定位有效 {locatedCount.toLocaleString()} 辆,降级 {degradedCount.toLocaleString()} 辆,超时 {staleCount.toLocaleString()} 辆。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Tag color={pageCoverageRate >= 100 ? 'green' : 'grey'}>覆盖 {formatPercent(pageCoverageRate)}</Tag>
|
||
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德可用' : '坐标预览'}</Tag>
|
||
<Tag color={rows.length - onlineCount > 0 ? 'orange' : 'green'}>{(rows.length - onlineCount).toLocaleString()} 辆离线</Tag>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-realtime-impact-grid">
|
||
{realtimeImpactItems.map((item) => (
|
||
<div key={item.label} className="vp-realtime-impact-item">
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card bordered title="实时覆盖判读" style={{ marginBottom: 16 }}>
|
||
<div className="vp-stat-insight-grid">
|
||
{[
|
||
{ label: '在线率', value: formatPercent(onlineRate), color: onlineRate >= 80 ? 'green' as const : 'orange' as const, detail: `${onlineCount.toLocaleString()} / ${rows.length.toLocaleString()} 辆在线。` },
|
||
{ label: '定位有效率', value: formatPercent(locatedRate), color: locatedRate >= 80 ? 'green' as const : 'orange' as const, detail: `${locatedCount.toLocaleString()} / ${rows.length.toLocaleString()} 辆坐标有效。` },
|
||
{ label: '新鲜数据', value: `${(rows.length - staleCount).toLocaleString()} 辆`, color: staleCount > 0 ? 'orange' as const : 'green' as const, detail: `${staleCount.toLocaleString()} 辆超过 5 分钟未更新。` },
|
||
{ label: '降级率', value: formatPercent(degradedRate), color: degradedCount > 0 ? 'orange' as const : 'green' as const, detail: `${degradedCount.toLocaleString()} 辆存在来源不完整或离线。` },
|
||
{ label: '分页覆盖率', value: formatPercent(pageCoverageRate), color: pageCoverageRate >= 100 ? 'green' as const : 'grey' as const, detail: '当前页车辆数 / 当前筛选总车辆数。' }
|
||
].map((item) => (
|
||
<Card key={item.label} bordered>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<div className="vp-monitor-metric-value">{item.value}</div>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
<Tabs type="line">
|
||
<Tabs.TabPane tab="表格视图" itemKey="table">
|
||
<div className="vp-table-toolbar">
|
||
<Space wrap>
|
||
<Tag color="blue">当前页 {rows.length.toLocaleString()} 条</Tag>
|
||
<Button size="small" onClick={exportRealtime}>导出实时当前页 CSV</Button>
|
||
</Space>
|
||
</div>
|
||
{rows.length === 0 && !loading ? (
|
||
<DataEmpty />
|
||
) : (
|
||
<Table
|
||
loading={loading}
|
||
rowKey="vin"
|
||
dataSource={rows}
|
||
pagination={{
|
||
currentPage: pagination.currentPage,
|
||
pageSize: pagination.pageSize,
|
||
total: pagination.total,
|
||
showSizeChanger: true,
|
||
onPageChange: (page) => load(filters, page, pagination.pageSize),
|
||
onPageSizeChange: (pageSize) => load(filters, 1, pageSize)
|
||
}}
|
||
columns={[
|
||
{ title: '车牌', dataIndex: 'plate', width: 120 },
|
||
{ title: 'VIN', dataIndex: 'vin', width: 190 },
|
||
{
|
||
title: '车辆服务状态',
|
||
width: 130,
|
||
render: (_: unknown, row: VehicleRealtimeRow) => {
|
||
const status = vehicleServiceStatus(row);
|
||
return <Tag color={status.color}>{status.label}</Tag>;
|
||
}
|
||
},
|
||
{
|
||
title: '数据新鲜度',
|
||
width: 130,
|
||
render: (_: unknown, row: VehicleRealtimeRow) => {
|
||
const freshness = dataFreshness(row);
|
||
return <Tag color={freshness.color}>{freshness.label}</Tag>;
|
||
}
|
||
},
|
||
{
|
||
title: '车辆核心数据',
|
||
width: 230,
|
||
render: (_: unknown, row: VehicleRealtimeRow) => (
|
||
<Space spacing={4} wrap>
|
||
<Tag color="blue">{row.speedKmh ?? '-'} km/h</Tag>
|
||
<Tag color="green">SOC {row.socPercent ?? '-'}%</Tag>
|
||
<Typography.Text type="tertiary">{row.totalMileageKm ?? '-'} km</Typography.Text>
|
||
</Space>
|
||
)
|
||
},
|
||
{
|
||
title: '来源证据',
|
||
width: 260,
|
||
render: (_: unknown, row: VehicleRealtimeRow) => (
|
||
<SourceStatusTags sourceStatus={row.sourceStatus} protocols={row.protocols} lastSeen={row.lastSeen} />
|
||
)
|
||
},
|
||
{ title: '证据覆盖', width: 120, render: (_: unknown, row: VehicleRealtimeRow) => sourceEvidenceText(row) },
|
||
{ title: '在线', width: 90, render: (_: unknown, row: VehicleRealtimeRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
|
||
{ title: '最后时间', dataIndex: 'lastSeen', width: 170 },
|
||
{
|
||
title: '操作',
|
||
width: 190,
|
||
render: (_: unknown, row: VehicleRealtimeRow) => (
|
||
<Space spacing={4} wrap>
|
||
<Button disabled={!isValidCoordinate(row)} onClick={() => selectRealtimeRow(row)}>地图定位</Button>
|
||
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, filters.protocol || row.primaryProtocol)}>车辆服务</Button>
|
||
<Button disabled={!canOpenVehicle(row.vin) || !onOpenQuality} onClick={() => openQualityEvidence(row)}>质量问题</Button>
|
||
</Space>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
)}
|
||
</Tabs.TabPane>
|
||
<Tabs.TabPane tab="地图视图" itemKey="map">
|
||
<VehicleMap
|
||
points={mapPoints}
|
||
heightClassName=""
|
||
selectedId={selectedMapPointKey}
|
||
onPointSelect={selectRealtimeMapPoint}
|
||
fallbackLabel="高德地图未配置,显示实时坐标预览"
|
||
/>
|
||
</Tabs.TabPane>
|
||
</Tabs>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|