2510 lines
122 KiB
TypeScript
2510 lines
122 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) {
|
||
const title = row.serviceStatus.status === 'degraded' || row.serviceStatus.title === '来源不完整'
|
||
? '数据通道不完整'
|
||
: row.serviceStatus.title;
|
||
return {
|
||
label: 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 padDatePart(value: number) {
|
||
return String(value).padStart(2, '0');
|
||
}
|
||
|
||
function formatDateTime(value: Date) {
|
||
return [
|
||
value.getFullYear(),
|
||
padDatePart(value.getMonth() + 1),
|
||
padDatePart(value.getDate())
|
||
].join('-') + ` ${padDatePart(value.getHours())}:${padDatePart(value.getMinutes())}:${padDatePart(value.getSeconds())}`;
|
||
}
|
||
|
||
function defaultTimeWindow() {
|
||
const end = new Date();
|
||
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
|
||
return { dateFrom: formatDateTime(start), dateTo: formatDateTime(end) };
|
||
}
|
||
|
||
function timeWindowDurationText(dateFrom: string, dateTo: string) {
|
||
const start = new Date(dateFrom.replace(' ', 'T')).getTime();
|
||
const end = new Date(dateTo.replace(' ', 'T')).getTime();
|
||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
||
return '时间窗待确认';
|
||
}
|
||
const minutes = Math.round((end - start) / 60000);
|
||
if (minutes < 60) return `${minutes} 分钟窗口`;
|
||
if (minutes < 1440) return `${Math.round(minutes / 60)} 小时窗口`;
|
||
return `${Math.round(minutes / 1440)} 天窗口`;
|
||
}
|
||
|
||
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 }))}`,
|
||
` 历史查询:${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 === '实时稳定' ? '保持监控并关注告警队列' : '优先处理离线、超时和数据通道不完整车辆,并回到轨迹和历史查询复核'}`,
|
||
`实时监控:${appURL(buildAppHash({ page: 'realtime', protocol: filters.protocol, filters }))}`
|
||
].join('\n');
|
||
}
|
||
|
||
function mapCustomerPackageText({
|
||
filters,
|
||
rows,
|
||
total,
|
||
onlineCount,
|
||
locatedCount,
|
||
attentionCount,
|
||
staleCount,
|
||
selectedRow,
|
||
selectedProtocol,
|
||
amapConfigured
|
||
}: {
|
||
filters: Record<string, string>;
|
||
rows: VehicleRealtimeRow[];
|
||
total: number;
|
||
onlineCount: number;
|
||
locatedCount: number;
|
||
attentionCount: number;
|
||
staleCount: number;
|
||
selectedRow?: VehicleRealtimeRow;
|
||
selectedProtocol?: string;
|
||
amapConfigured: boolean;
|
||
}) {
|
||
const selectedVIN = selectedRow?.vin || '';
|
||
return [
|
||
'【客户地图监控包】',
|
||
`当前筛选:${realtimeFilterSummary(filters).join(';') || '全部车辆'}`,
|
||
`车辆范围:当前页 ${rows.length.toLocaleString()} / 总计 ${total.toLocaleString()}`,
|
||
`在线态势:${onlineCount.toLocaleString()} 在线 / ${(rows.length - onlineCount).toLocaleString()} 离线`,
|
||
`定位态势:${locatedCount.toLocaleString()} 辆有有效坐标`,
|
||
`关注车辆:${attentionCount.toLocaleString()} 辆;更新超时 ${staleCount.toLocaleString()} 辆`,
|
||
`地图能力:${amapConfigured ? '高德地图可用' : '坐标预览'}`,
|
||
`选中车辆:${selectedRow ? `${selectedRow.plate || '-'} / ${selectedVIN} / ${selectedProtocol || selectedRow.primaryProtocol || '-'}` : '未选择'}`,
|
||
selectedRow ? `选中车辆状态:${vehicleServiceStatus(selectedRow).label};${dataFreshness(selectedRow).detail};${realtimeIssueLabels(selectedRow).join(';')}` : '',
|
||
selectedRow ? `选中车辆位置:${isValidCoordinate(selectedRow) ? `${selectedRow.longitude},${selectedRow.latitude}` : '无有效坐标'}` : '',
|
||
`实时地图:${appURL(buildAppHash({ page: 'map', protocol: filters.protocol, filters }))}`,
|
||
selectedVIN ? `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: selectedVIN, protocol: selectedProtocol }))}` : '',
|
||
selectedVIN ? `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: selectedVIN, protocol: selectedProtocol }))}` : '',
|
||
selectedVIN ? `统计查询:${appURL(buildAppHash({ page: 'mileage', keyword: selectedVIN, protocol: selectedProtocol }))}` : '',
|
||
selectedVIN ? `历史数据:${appURL(buildAppHash({ page: 'history-query', keyword: selectedVIN, protocol: selectedProtocol, filters: { tab: 'raw', includeFields: 'true' } }))}` : '',
|
||
selectedVIN ? `告警通知:${appURL(buildAppHash({ page: 'alert-events', keyword: selectedVIN, protocol: selectedProtocol }))}` : ''
|
||
].filter(Boolean).join('\n');
|
||
}
|
||
|
||
function mapCustomerDecisionText({
|
||
filters,
|
||
rows,
|
||
total,
|
||
onlineCount,
|
||
locatedCount,
|
||
attentionRows,
|
||
selectedRow,
|
||
selectedProtocol,
|
||
amapConfigured
|
||
}: {
|
||
filters: Record<string, string>;
|
||
rows: VehicleRealtimeRow[];
|
||
total: number;
|
||
onlineCount: number;
|
||
locatedCount: number;
|
||
attentionRows: VehicleRealtimeRow[];
|
||
selectedRow?: VehicleRealtimeRow;
|
||
selectedProtocol?: string;
|
||
amapConfigured: boolean;
|
||
}) {
|
||
const selectedVIN = selectedRow?.vin || '';
|
||
const topAttention = attentionRows[0];
|
||
return [
|
||
'【地图客户决策说明】',
|
||
`当前筛选:${realtimeFilterSummary(filters).join(';') || '全部车辆'}`,
|
||
`车辆范围:当前页 ${rows.length.toLocaleString()} / 总计 ${total.toLocaleString()}`,
|
||
`在线判断:${onlineCount.toLocaleString()} 在线 / ${(rows.length - onlineCount).toLocaleString()} 离线`,
|
||
`定位判断:${locatedCount.toLocaleString()} 辆有有效坐标 / ${Math.max(0, rows.length - locatedCount).toLocaleString()} 辆无坐标`,
|
||
`关注判断:${attentionRows.length.toLocaleString()} 辆需要关注${topAttention ? `;优先车辆 ${topAttention.plate || topAttention.vin}:${realtimeIssueLabels(topAttention).join(';')}` : ''}`,
|
||
`地图能力:${amapConfigured ? '高德地图可用' : '坐标预览'}`,
|
||
`选中车辆:${selectedRow ? `${selectedRow.plate || '-'} / ${selectedVIN} / ${selectedProtocol || selectedRow.primaryProtocol || '-'}` : '未选择'}`,
|
||
selectedRow ? `选中车辆下一步:${vehicleServiceStatus(selectedRow).label};${dataFreshness(selectedRow).detail};${realtimeIssueLabels(selectedRow).join(';')}` : '选中车辆下一步:先从地图或车辆列表选择一辆车',
|
||
`实时地图:${appURL(buildAppHash({ page: 'map', protocol: filters.protocol, filters }))}`,
|
||
`在线车辆:${appURL(buildAppHash({ page: 'map', protocol: filters.protocol, filters: { ...filters, online: 'online' } }))}`,
|
||
selectedVIN ? `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: selectedVIN, protocol: selectedProtocol }))}` : '',
|
||
selectedVIN ? `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: selectedVIN, protocol: selectedProtocol }))}` : '',
|
||
selectedVIN ? `历史查询导出:${appURL(buildAppHash({ page: 'history-query', keyword: selectedVIN, protocol: selectedProtocol, filters: { tab: 'raw', includeFields: 'true' } }))}` : ''
|
||
].filter(Boolean).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({
|
||
mode = 'realtime',
|
||
title = '实时监控',
|
||
description = '以车辆为主对象查看最新位置、在线状态、核心实时数据和地图作业状态',
|
||
onOpenVehicle,
|
||
onOpenHistory,
|
||
onOpenQuality,
|
||
onFiltersChange,
|
||
initialFilters = {}
|
||
}: {
|
||
mode?: 'realtime' | 'map';
|
||
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 [timeWindow, setTimeWindow] = useState(defaultTimeWindow);
|
||
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 timeWindowRow = selectedMapRow && canOpenVehicle(selectedMapRow.vin)
|
||
? selectedMapRow
|
||
: rows.find((row) => canOpenVehicle(row.vin));
|
||
const timeWindowKeyword = timeWindowRow?.vin || filters.keyword || '';
|
||
const timeWindowProtocol = timeWindowRow ? filters.protocol || timeWindowRow.primaryProtocol || '' : filters.protocol || '';
|
||
const timeWindowReady = Boolean(timeWindowKeyword && timeWindow.dateFrom && timeWindow.dateTo);
|
||
const timeWindowDuration = timeWindowDurationText(timeWindow.dateFrom, timeWindow.dateTo);
|
||
const setTimeWindowPreset = (preset: '15m' | '1h' | 'today' | '24h') => {
|
||
const end = new Date();
|
||
const start = new Date(end);
|
||
if (preset === '15m') start.setMinutes(end.getMinutes() - 15);
|
||
if (preset === '1h') start.setHours(end.getHours() - 1);
|
||
if (preset === '24h') start.setDate(end.getDate() - 1);
|
||
if (preset === 'today') {
|
||
start.setHours(0, 0, 0, 0);
|
||
}
|
||
setTimeWindow({ dateFrom: formatDateTime(start), dateTo: formatDateTime(end) });
|
||
};
|
||
const timeWindowFilters = (extra: Record<string, string> = {}) => ({
|
||
keyword: timeWindowKeyword,
|
||
protocol: timeWindowProtocol,
|
||
dateFrom: timeWindow.dateFrom,
|
||
dateTo: timeWindow.dateTo,
|
||
...extra
|
||
});
|
||
const openTimeWindowHistory = () => {
|
||
if (!timeWindowReady) {
|
||
Toast.warning('请先选择车辆和时间窗');
|
||
return;
|
||
}
|
||
window.location.hash = buildAppHash({ page: 'history', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() });
|
||
};
|
||
const openTimeWindowRaw = () => {
|
||
if (!timeWindowReady) {
|
||
Toast.warning('请先选择车辆和时间窗');
|
||
return;
|
||
}
|
||
window.location.hash = buildAppHash({
|
||
page: 'history-query',
|
||
keyword: timeWindowKeyword,
|
||
protocol: timeWindowProtocol,
|
||
filters: timeWindowFilters({ tab: 'raw', includeFields: 'true' })
|
||
});
|
||
};
|
||
const openTimeWindowMileage = () => {
|
||
if (!timeWindowReady) {
|
||
Toast.warning('请先选择车辆和时间窗');
|
||
return;
|
||
}
|
||
window.location.hash = buildAppHash({ page: 'mileage', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() });
|
||
};
|
||
const openTimeWindowQuality = () => {
|
||
if (!timeWindowReady || !onOpenQuality) {
|
||
Toast.warning('请先选择车辆和时间窗');
|
||
return;
|
||
}
|
||
onOpenQuality(timeWindowFilters());
|
||
};
|
||
const copyTimeWindowPackage = () => copyText([
|
||
'【客户时间窗复盘包】',
|
||
`车辆:${timeWindowRow ? [timeWindowRow.plate, timeWindowRow.vin].filter(Boolean).join(' / ') : timeWindowKeyword || '-'}`,
|
||
`数据通道:${timeWindowProtocol || '全部数据通道'}`,
|
||
`时间窗:${timeWindow.dateFrom} 至 ${timeWindow.dateTo}`,
|
||
`时间窗判定:${timeWindowDuration}`,
|
||
`实时状态:${timeWindowRow ? `${vehicleServiceStatus(timeWindowRow).label} / ${dataFreshness(timeWindowRow).detail}` : '未选车辆'}`,
|
||
`轨迹回放:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'history', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }) : '-'}`,
|
||
`历史数据:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'history-query', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters({ tab: 'raw', includeFields: 'true' }) }) : '-'}`,
|
||
`统计查询:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'mileage', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }) : '-'}`,
|
||
`告警通知:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'alert-events', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }) : '-'}`
|
||
].join('\n'), '客户时间窗复盘包');
|
||
const timeWindowWorkItems = [
|
||
{
|
||
title: '轨迹回放',
|
||
value: timeWindowDuration,
|
||
detail: '按时间窗回放位置、速度、里程断点。',
|
||
action: '打开轨迹',
|
||
color: 'blue' as const,
|
||
disabled: !timeWindowReady,
|
||
onClick: openTimeWindowHistory
|
||
},
|
||
{
|
||
title: '历史数据',
|
||
value: timeWindowProtocol || '全部通道',
|
||
detail: '查看该时间窗内的历史明细和字段。',
|
||
action: '查询历史',
|
||
color: 'blue' as const,
|
||
disabled: !timeWindowReady,
|
||
onClick: openTimeWindowRaw
|
||
},
|
||
{
|
||
title: '统计查询',
|
||
value: timeWindowRow?.totalMileageKm != null ? `${timeWindowRow.totalMileageKm} km` : '待核对',
|
||
detail: '核对区间里程、日里程和总里程差值。',
|
||
action: '查看统计',
|
||
color: 'orange' as const,
|
||
disabled: !timeWindowReady,
|
||
onClick: openTimeWindowMileage
|
||
},
|
||
{
|
||
title: '告警通知',
|
||
value: timeWindowRow ? realtimeIssueLabels(timeWindowRow)[0] : '待选择',
|
||
detail: '查看时间窗内断链、离线、定位异常事件。',
|
||
action: '查看告警',
|
||
color: timeWindowRow && hasSourceIssue(timeWindowRow) ? 'orange' as const : 'green' as const,
|
||
disabled: !timeWindowReady || !onOpenQuality,
|
||
onClick: openTimeWindowQuality
|
||
}
|
||
];
|
||
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);
|
||
};
|
||
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()} 辆更新超时` }
|
||
];
|
||
const missingCoordinateCount = Math.max(0, rows.length - locatedCount);
|
||
const selectedVehicleProtocol = selectedMapRow ? filters.protocol || selectedMapRow.primaryProtocol || '' : '';
|
||
const selectedVehicleLabel = selectedMapRow?.plate || selectedMapRow?.vin || '未选择车辆';
|
||
const openSelectedVehicleRaw = () => {
|
||
if (!selectedMapRow || !canOpenVehicle(selectedMapRow.vin)) {
|
||
Toast.warning('请先选择可查询的车辆');
|
||
return;
|
||
}
|
||
window.location.hash = buildAppHash({
|
||
page: 'history-query',
|
||
keyword: selectedMapRow.vin,
|
||
protocol: selectedVehicleProtocol,
|
||
filters: { tab: 'raw', includeFields: 'true' }
|
||
});
|
||
};
|
||
const mapVehicleWorkItems = [
|
||
{
|
||
title: '车辆服务',
|
||
value: selectedVehicleLabel,
|
||
detail: selectedMapRow ? '查看车辆画像、数据通道状态和最新实时字段。' : '先从地图或车辆列表选择一辆车。',
|
||
action: '车辆档案',
|
||
color: selectedMapRow ? vehicleServiceStatus(selectedMapRow).color : 'grey' as const,
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onClick: () => selectedMapRow && onOpenVehicle(selectedMapRow.vin, selectedVehicleProtocol)
|
||
},
|
||
{
|
||
title: '轨迹复盘',
|
||
value: selectedMapRow ? dataFreshness(selectedMapRow).detail : '等待选择',
|
||
detail: '复核位置、速度、里程和断点,支撑客户问询。',
|
||
action: '打开轨迹',
|
||
color: selectedMapRow ? dataFreshness(selectedMapRow).color : 'grey' as const,
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onClick: () => selectedMapRow && onOpenHistory?.(selectedMapRow.vin, selectedVehicleProtocol)
|
||
},
|
||
{
|
||
title: '统计查询',
|
||
value: selectedMapRow?.totalMileageKm != null ? `${selectedMapRow.totalMileageKm} km` : '无里程',
|
||
detail: '进入单车统计查询,核对日里程和区间差值。',
|
||
action: '统计查询',
|
||
color: selectedMapRow?.totalMileageKm != null ? 'blue' as const : 'orange' as const,
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onClick: () => {
|
||
if (!selectedMapRow) return;
|
||
window.location.hash = buildAppHash({
|
||
page: 'mileage',
|
||
keyword: selectedMapRow.vin,
|
||
protocol: selectedVehicleProtocol
|
||
});
|
||
}
|
||
},
|
||
{
|
||
title: '告警处置',
|
||
value: selectedMapRow ? realtimeIssueLabels(selectedMapRow)[0] : '等待选择',
|
||
detail: '围绕选中车辆查看断链、离线、定位异常等事件。',
|
||
action: '查看告警',
|
||
color: selectedMapRow && hasSourceIssue(selectedMapRow) ? 'orange' as const : selectedMapRow ? 'green' as const : 'grey' as const,
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin) || !onOpenQuality,
|
||
onClick: () => selectedMapRow && onOpenQuality?.({
|
||
keyword: selectedMapRow.vin,
|
||
protocol: selectedVehicleProtocol
|
||
})
|
||
}
|
||
];
|
||
const mapCustomerTasks = [
|
||
{
|
||
title: '实时盯车',
|
||
value: `${onlineCount.toLocaleString()} 在线`,
|
||
detail: '先看在线车辆和有效定位,确认客户当前能看到车在哪里。',
|
||
color: 'green' as const,
|
||
primaryAction: '只看在线',
|
||
secondaryAction: '刷新地图',
|
||
onPrimary: () => applyFilters({ ...filters, online: 'online' }),
|
||
onSecondary: () => load(filters, pagination.currentPage, pagination.pageSize),
|
||
disabled: false,
|
||
secondaryDisabled: false
|
||
},
|
||
{
|
||
title: '异常优先',
|
||
value: `${mapAttentionRows.length.toLocaleString()} 辆关注`,
|
||
detail: mapAttentionRows[0] ? `${mapAttentionRows[0].plate || mapAttentionRows[0].vin}:${realtimeIssueLabels(mapAttentionRows[0]).join(';')}` : '当前筛选下暂无需要关注的车辆。',
|
||
color: mapAttentionRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
primaryAction: '关注车辆',
|
||
secondaryAction: '告警事件',
|
||
onPrimary: () => applyFilters({ ...filters, serviceStatus: 'degraded' }),
|
||
onSecondary: () => onOpenQuality?.({ serviceStatus: 'degraded' }),
|
||
disabled: !onOpenQuality && mapAttentionRows.length === 0,
|
||
secondaryDisabled: !onOpenQuality
|
||
},
|
||
{
|
||
title: '轨迹复盘',
|
||
value: selectedMapRow?.plate || selectedMapRow?.vin || '未选车辆',
|
||
detail: selectedMapRow ? `围绕 ${selectedMapRow.plate || selectedMapRow.vin} 回放位置、速度和里程变化。` : '从地图或车辆队列选择一辆车后回放轨迹。',
|
||
color: selectedMapRow ? 'blue' as const : 'grey' as const,
|
||
primaryAction: '轨迹回放',
|
||
secondaryAction: '车辆档案',
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onPrimary: () => selectedMapRow && onOpenHistory?.(selectedMapRow.vin, selectedVehicleProtocol),
|
||
onSecondary: () => selectedMapRow && onOpenVehicle(selectedMapRow.vin, selectedVehicleProtocol),
|
||
secondaryDisabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin)
|
||
},
|
||
{
|
||
title: '查询导出',
|
||
value: `${rows.length.toLocaleString()} 辆当前页`,
|
||
detail: '导出当前车辆清单,或进入选中车辆的历史查询。',
|
||
color: 'blue' as const,
|
||
primaryAction: '导出当前页',
|
||
secondaryAction: '历史查询',
|
||
onPrimary: exportRealtime,
|
||
onSecondary: openSelectedVehicleRaw,
|
||
disabled: false,
|
||
secondaryDisabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin)
|
||
}
|
||
];
|
||
const mapServiceActionItems = [
|
||
{
|
||
title: '在线车辆',
|
||
value: `${onlineCount.toLocaleString()} 在线`,
|
||
detail: `在线率 ${formatPercent(onlineRate)},先确认客户当前能看到哪些车。`,
|
||
action: '只看在线',
|
||
color: onlineCount > 0 ? 'green' as const : 'orange' as const,
|
||
disabled: false,
|
||
onClick: () => applyFilters({ ...filters, online: 'online' })
|
||
},
|
||
{
|
||
title: '关注车辆',
|
||
value: `${mapAttentionRows.length.toLocaleString()} 关注`,
|
||
detail: mapAttentionRows[0] ? `${mapAttentionRows[0].plate || mapAttentionRows[0].vin}:${realtimeIssueLabels(mapAttentionRows[0]).join(';')}` : '当前没有明显离线、超时或坐标异常车辆。',
|
||
action: '异常优先',
|
||
color: mapAttentionRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
disabled: false,
|
||
onClick: () => applyFilters({ ...filters, serviceStatus: 'degraded' })
|
||
},
|
||
{
|
||
title: '路线回放',
|
||
value: selectedMapRow?.plate || selectedMapRow?.vin || '先选车',
|
||
detail: selectedMapRow ? `${dataFreshness(selectedMapRow).detail},回放位置、速度和里程变化。` : '从地图或车辆列表选择一辆车后进入轨迹回放。',
|
||
action: '轨迹回放',
|
||
color: selectedMapRow ? 'blue' as const : 'grey' as const,
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onClick: () => selectedMapRow && onOpenHistory?.(selectedMapRow.vin, selectedVehicleProtocol)
|
||
},
|
||
{
|
||
title: '统计查询',
|
||
value: selectedMapRow?.totalMileageKm != null ? `${selectedMapRow.totalMileageKm} km` : '待选车',
|
||
detail: '按选中车辆进入统计查询,核对区间里程和日报闭合。',
|
||
action: '统计查询',
|
||
color: selectedMapRow?.totalMileageKm != null ? 'blue' as const : 'grey' as const,
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onClick: () => {
|
||
if (!selectedMapRow) return;
|
||
window.location.hash = buildAppHash({
|
||
page: 'mileage',
|
||
keyword: selectedMapRow.vin,
|
||
protocol: selectedVehicleProtocol
|
||
});
|
||
}
|
||
},
|
||
{
|
||
title: '证据导出',
|
||
value: `${rows.length.toLocaleString()} 当前页`,
|
||
detail: '导出当前地图车辆清单,作为客户问询和运营交接证据。',
|
||
action: '导出CSV',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: exportRealtime
|
||
}
|
||
];
|
||
const mapFieldCommandItems = [
|
||
{
|
||
title: '全域定位',
|
||
value: `${locatedCount.toLocaleString()} 辆有坐标`,
|
||
detail: amapConfigured ? '用高德地图查看车辆分布、在线状态和最近上报位置。' : '高德不可用时先用坐标预览确认车辆分布。',
|
||
color: locatedCount > 0 ? 'blue' as const : 'orange' as const,
|
||
action: '刷新地图',
|
||
disabled: false,
|
||
onClick: () => load(filters, pagination.currentPage, pagination.pageSize)
|
||
},
|
||
{
|
||
title: '关注车辆',
|
||
value: mapAttentionRows.length > 0 ? `${mapAttentionRows.length.toLocaleString()} 辆待处理` : '暂无异常',
|
||
detail: mapAttentionRows[0]
|
||
? `${mapAttentionRows[0].plate || mapAttentionRows[0].vin}:${realtimeIssueLabels(mapAttentionRows[0]).join(';')}`
|
||
: '当前筛选下车辆在线、定位和新鲜度未形成主要风险。',
|
||
color: mapAttentionRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
action: '定位关注车',
|
||
disabled: mapAttentionRows.length === 0,
|
||
onClick: () => mapAttentionRows[0] && selectRealtimeRow(mapAttentionRows[0])
|
||
},
|
||
{
|
||
title: '轨迹复盘',
|
||
value: selectedVehicleLabel,
|
||
detail: selectedMapRow ? '打开选中车辆轨迹,核对路径、速度、里程和停留。' : '先选择车辆,再进入轨迹回放和时间窗复盘。',
|
||
color: selectedMapRow ? 'blue' as const : 'grey' as const,
|
||
action: '打开轨迹',
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onClick: () => selectedMapRow && onOpenHistory?.(selectedMapRow.vin, selectedVehicleProtocol)
|
||
},
|
||
{
|
||
title: '通知证据',
|
||
value: mapAttentionRows.length > 0 ? '需通知' : '可交付',
|
||
detail: mapAttentionRows.length > 0 ? '复制地图监控包后进入告警事件,形成通知和处置闭环。' : '地图态势稳定时,可直接复制地图监控包用于交接。',
|
||
color: mapAttentionRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
action: mapAttentionRows.length > 0 ? '告警事件' : '复制监控包',
|
||
disabled: false,
|
||
onClick: () => mapAttentionRows.length > 0 && onOpenQuality ? onOpenQuality({ serviceStatus: 'degraded' }) : copyMapCustomerPackage()
|
||
}
|
||
];
|
||
const mapDispatchActionItems = [
|
||
{
|
||
title: '关注车辆',
|
||
value: `${mapAttentionRows.length.toLocaleString()} 辆`,
|
||
detail: mapAttentionRows[0]
|
||
? `${mapAttentionRows[0].plate || mapAttentionRows[0].vin}:${realtimeIssueLabels(mapAttentionRows[0]).join(';')}`
|
||
: '当前地图范围没有需要立即处置的车辆。',
|
||
color: mapAttentionRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
action: '告警事件',
|
||
disabled: mapAttentionRows.length === 0 || !onOpenQuality,
|
||
onClick: () => onOpenQuality?.({ serviceStatus: 'degraded' })
|
||
},
|
||
{
|
||
title: '复盘选中',
|
||
value: selectedVehicleLabel,
|
||
detail: selectedMapRow ? `${dataFreshness(selectedMapRow).detail},回放轨迹和里程变化。` : '先从地图或列表选择车辆。',
|
||
color: selectedMapRow ? dataFreshness(selectedMapRow).color : 'grey' as const,
|
||
action: '轨迹回放',
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onClick: () => selectedMapRow && onOpenHistory?.(selectedMapRow.vin, selectedVehicleProtocol)
|
||
},
|
||
{
|
||
title: '车辆档案',
|
||
value: selectedVehicleLabel,
|
||
detail: selectedMapRow ? `${vehicleServiceStatus(selectedMapRow).label},查看车辆画像和来源证据。` : '选择车辆后进入单车服务。',
|
||
color: selectedMapRow ? vehicleServiceStatus(selectedMapRow).color : 'grey' as const,
|
||
action: '车辆服务',
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onClick: () => selectedMapRow && onOpenVehicle(selectedMapRow.vin, selectedVehicleProtocol)
|
||
},
|
||
{
|
||
title: '交接证据',
|
||
value: `${rows.length.toLocaleString()} 辆`,
|
||
detail: '导出当前地图车辆清单,交接给客户或运营人员继续处理。',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
action: '导出CSV',
|
||
disabled: rows.length === 0,
|
||
onClick: exportRealtime
|
||
}
|
||
];
|
||
const mapCustomerDecisionItems = [
|
||
{
|
||
label: '先看在线',
|
||
value: `${onlineCount.toLocaleString()} 在线`,
|
||
detail: `${(rows.length - onlineCount).toLocaleString()} 辆离线,先确认客户能看到哪些车还在上报。`,
|
||
color: onlineCount > 0 ? 'green' as const : 'orange' as const,
|
||
action: '只看在线',
|
||
onClick: () => applyFilters({ ...filters, online: 'online' })
|
||
},
|
||
{
|
||
label: '再看定位',
|
||
value: `${locatedCount.toLocaleString()} 有坐标`,
|
||
detail: `${missingCoordinateCount.toLocaleString()} 辆无有效坐标,地图可视范围决定客户能否看车。`,
|
||
color: locatedCount > 0 ? 'blue' as const : 'orange' as const,
|
||
action: '地图态势',
|
||
onClick: () => load(filters, pagination.currentPage, pagination.pageSize)
|
||
},
|
||
{
|
||
label: '优先异常',
|
||
value: `${mapAttentionRows.length.toLocaleString()} 关注`,
|
||
detail: mapAttentionRows[0] ? `${mapAttentionRows[0].plate || mapAttentionRows[0].vin}:${realtimeIssueLabels(mapAttentionRows[0]).join(';')}` : '当前没有明显离线、超时或坐标异常车辆。',
|
||
color: mapAttentionRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
action: '告警事件',
|
||
onClick: () => onOpenQuality?.({ serviceStatus: 'degraded' })
|
||
},
|
||
{
|
||
label: '选车复盘',
|
||
value: selectedVehicleLabel,
|
||
detail: selectedMapRow ? `${vehicleServiceStatus(selectedMapRow).label},${dataFreshness(selectedMapRow).detail}` : '从地图或车辆列表选择一辆车后进入轨迹、统计查询和历史查询。',
|
||
color: selectedMapRow ? vehicleServiceStatus(selectedMapRow).color : 'grey' as const,
|
||
action: '轨迹回放',
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onClick: () => selectedMapRow && onOpenHistory?.(selectedMapRow.vin, selectedVehicleProtocol)
|
||
}
|
||
];
|
||
const mapCustomerPackageItems = [
|
||
{
|
||
label: '车辆范围',
|
||
value: `${rows.length.toLocaleString()} / ${pagination.total.toLocaleString()}`,
|
||
detail: `当前筛选覆盖 ${formatPercent(pageCoverageRate)}`,
|
||
color: pageCoverageRate >= 100 ? 'green' as const : 'blue' 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 ? 'green' as const : 'orange' as const
|
||
},
|
||
{
|
||
label: '关注车辆',
|
||
value: `${mapAttentionRows.length.toLocaleString()} 辆`,
|
||
detail: `${staleCount.toLocaleString()} 辆更新超时`,
|
||
color: mapAttentionRows.length > 0 ? 'orange' as const : 'green' as const
|
||
},
|
||
{
|
||
label: '选中车辆',
|
||
value: selectedVehicleLabel,
|
||
detail: selectedMapRow ? `${vehicleServiceStatus(selectedMapRow).label} / ${dataFreshness(selectedMapRow).detail}` : '从地图或车辆列表选择',
|
||
color: selectedMapRow ? vehicleServiceStatus(selectedMapRow).color : 'grey' as const
|
||
}
|
||
];
|
||
const copyMapCustomerPackage = () => copyText(mapCustomerPackageText({
|
||
filters,
|
||
rows,
|
||
total: pagination.total,
|
||
onlineCount,
|
||
locatedCount,
|
||
attentionCount: mapAttentionRows.length,
|
||
staleCount,
|
||
selectedRow: selectedMapRow,
|
||
selectedProtocol: selectedVehicleProtocol,
|
||
amapConfigured
|
||
}), '客户地图监控包');
|
||
const copyMapCustomerDecision = () => copyText(mapCustomerDecisionText({
|
||
filters,
|
||
rows,
|
||
total: pagination.total,
|
||
onlineCount,
|
||
locatedCount,
|
||
attentionRows: mapAttentionRows,
|
||
selectedRow: selectedMapRow,
|
||
selectedProtocol: selectedVehicleProtocol,
|
||
amapConfigured
|
||
}), '地图客户决策说明');
|
||
const realtimeCustomerJourneyItems = [
|
||
{
|
||
step: '01',
|
||
title: '只看在线',
|
||
value: `${onlineCount.toLocaleString()} 在线`,
|
||
detail: '先确认客户当前能看到哪些车辆仍在上报。',
|
||
action: '在线车辆',
|
||
color: onlineCount > 0 ? 'green' as const : 'orange' as const,
|
||
onClick: () => applyFilters({ ...filters, online: 'online' }),
|
||
disabled: false
|
||
},
|
||
{
|
||
step: '02',
|
||
title: '地图态势',
|
||
value: `${locatedCount.toLocaleString()} 有定位`,
|
||
detail: '在地图上确认车辆位置、定位有效性和分布情况。',
|
||
action: '查看地图',
|
||
color: locatedCount > 0 ? 'blue' as const : 'orange' as const,
|
||
onClick: () => {
|
||
window.location.hash = buildAppHash({ page: 'map', protocol: filters.protocol, filters });
|
||
},
|
||
disabled: false
|
||
},
|
||
{
|
||
step: '03',
|
||
title: '异常优先',
|
||
value: `${Math.max(degradedCount, sourceIssueRows.length).toLocaleString()} 关注`,
|
||
detail: '优先处理离线、更新超时、坐标无效或服务降级车辆。',
|
||
action: '关注车辆',
|
||
color: degradedCount > 0 || sourceIssueRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
onClick: () => applyFilters({ ...filters, serviceStatus: 'degraded' }),
|
||
disabled: false
|
||
},
|
||
{
|
||
step: '04',
|
||
title: '单车复盘',
|
||
value: selectedMapRow?.plate || selectedMapRow?.vin || '未选车',
|
||
detail: selectedMapRow ? '围绕选中车辆回放轨迹、统计查询和告警。' : '先在地图或表格中选择一辆可查询车辆。',
|
||
action: '轨迹回放',
|
||
color: selectedMapRow ? 'blue' as const : 'grey' as const,
|
||
onClick: () => selectedMapRow && onOpenHistory?.(selectedMapRow.vin, selectedVehicleProtocol),
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin)
|
||
},
|
||
{
|
||
step: '05',
|
||
title: '查询导出',
|
||
value: `${rows.length.toLocaleString()} 当前页`,
|
||
detail: '导出当前实时车辆清单,用于客户问询和运营交接。',
|
||
action: '导出 CSV',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
onClick: exportRealtime,
|
||
disabled: rows.length === 0
|
||
}
|
||
];
|
||
const realtimeNextActions = [
|
||
{
|
||
level: degradedCount > 0 || staleCount > 0 ? '优先' : '巡检',
|
||
title: degradedCount > 0 || staleCount > 0 ? '先处理异常车辆' : '先看在线车辆',
|
||
value: degradedCount > 0 || staleCount > 0 ? `${degradedCount.toLocaleString()} 降级 / ${staleCount.toLocaleString()} 超时` : `${onlineCount.toLocaleString()} 在线`,
|
||
detail: degradedCount > 0 || staleCount > 0
|
||
? '离线、更新超时、坐标无效或服务降级会直接影响客户看车体验。'
|
||
: `当前页 ${locatedCount.toLocaleString()} 辆有有效坐标,可进入地图查看分布。`,
|
||
action: degradedCount > 0 || staleCount > 0 ? '关注异常' : '打开地图',
|
||
color: degradedCount > 0 || staleCount > 0 ? 'orange' as const : 'green' as const,
|
||
onClick: () => degradedCount > 0 || staleCount > 0 ? applyFilters({ ...filters, serviceStatus: 'degraded' }) : (window.location.hash = buildAppHash({ page: 'map', protocol: filters.protocol, filters }))
|
||
},
|
||
{
|
||
level: selectedMapRow ? '单车' : '选车',
|
||
title: selectedMapRow ? '复盘选中车辆' : '先选择一辆车',
|
||
value: selectedMapRow?.plate || selectedMapRow?.vin || '等待选车',
|
||
detail: selectedMapRow ? `${vehicleServiceStatus(selectedMapRow).label},${dataFreshness(selectedMapRow).detail}` : '从地图或实时列表选择车辆后,进入轨迹、统计查询、历史和告警。',
|
||
action: selectedMapRow ? '轨迹回放' : '查看车辆',
|
||
color: selectedMapRow ? vehicleServiceStatus(selectedMapRow).color : 'blue' as const,
|
||
disabled: selectedMapRow ? !canOpenVehicle(selectedMapRow.vin) : false,
|
||
onClick: () => selectedMapRow && canOpenVehicle(selectedMapRow.vin) ? onOpenHistory?.(selectedMapRow.vin, selectedVehicleProtocol) : applyFilters({ ...filters, online: 'online' })
|
||
},
|
||
{
|
||
level: '交付',
|
||
title: '导出当前实时清单',
|
||
value: `${rows.length.toLocaleString()} 辆当前页`,
|
||
detail: '客户问询或运营交接时,先导出当前筛选的车辆在线、位置、速度和里程。',
|
||
action: '导出 CSV',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: exportRealtime
|
||
}
|
||
];
|
||
const realtimeMapDispatchItems = [
|
||
{
|
||
title: '查看在线车辆',
|
||
value: `${onlineCount.toLocaleString()} 在线`,
|
||
detail: `定位有效 ${locatedCount.toLocaleString()} 辆,先确认客户当前能看到哪些车。`,
|
||
action: '实时地图',
|
||
color: onlineCount > 0 ? 'green' as const : 'orange' as const,
|
||
disabled: false,
|
||
onClick: () => {
|
||
window.location.hash = buildAppHash({ page: 'map', protocol: filters.protocol, filters: { ...filters, online: 'online' } });
|
||
}
|
||
},
|
||
{
|
||
title: '复盘选中车辆',
|
||
value: selectedMapRow?.plate || selectedMapRow?.vin || '先选车',
|
||
detail: selectedMapRow ? `${vehicleServiceStatus(selectedMapRow).label},${dataFreshness(selectedMapRow).detail}` : '从地图或实时列表选择车辆后,回放轨迹和里程变化。',
|
||
action: '轨迹回放',
|
||
color: selectedMapRow ? vehicleServiceStatus(selectedMapRow).color : 'grey' as const,
|
||
disabled: !selectedMapRow || !canOpenVehicle(selectedMapRow.vin),
|
||
onClick: () => selectedMapRow && onOpenHistory?.(selectedMapRow.vin, selectedVehicleProtocol)
|
||
},
|
||
{
|
||
title: '处理异常车辆',
|
||
value: `${mapAttentionRows.length.toLocaleString()} 关注`,
|
||
detail: mapAttentionRows[0] ? `${mapAttentionRows[0].plate || mapAttentionRows[0].vin}:${realtimeIssueLabels(mapAttentionRows[0]).join(';')}` : '当前筛选下暂无离线、超时或坐标异常车辆。',
|
||
action: '告警事件',
|
||
color: mapAttentionRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
disabled: !onOpenQuality,
|
||
onClick: () => onOpenQuality?.({ serviceStatus: 'degraded', protocol: filters.protocol })
|
||
},
|
||
{
|
||
title: '导出当前态势',
|
||
value: `${rows.length.toLocaleString()} 辆当前页`,
|
||
detail: '导出在线、位置、速度、SOC、里程和数据通道证据,用于客户交接。',
|
||
action: '导出CSV',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: exportRealtime
|
||
}
|
||
];
|
||
const realtimeCustomerQuestions = [
|
||
{
|
||
question: '这辆车现在在哪里?',
|
||
answer: selectedMapRow ? selectedMapRow.plate || selectedMapRow.vin : `${locatedCount.toLocaleString()} 辆有定位`,
|
||
evidence: selectedMapRow && isValidCoordinate(selectedMapRow) ? `${selectedMapRow.longitude}, ${selectedMapRow.latitude}` : `定位有效率 ${formatPercent(locatedRate)}`,
|
||
action: '地图定位',
|
||
color: locatedCount > 0 ? 'blue' as const : 'orange' as const,
|
||
disabled: false,
|
||
onClick: () => {
|
||
if (selectedMapRow) {
|
||
selectRealtimeRow(selectedMapRow);
|
||
}
|
||
window.location.hash = buildAppHash({ page: 'map', protocol: filters.protocol, filters });
|
||
}
|
||
},
|
||
{
|
||
question: '这辆车还在线吗?',
|
||
answer: selectedMapRow ? selectedMapRow.online ? '在线' : '离线' : `${onlineCount.toLocaleString()} 辆在线`,
|
||
evidence: selectedMapRow ? dataFreshness(selectedMapRow).detail : `在线率 ${formatPercent(onlineRate)}`,
|
||
action: '只看在线',
|
||
color: selectedMapRow ? selectedMapRow.online ? 'green' as const : 'orange' as const : onlineCount > 0 ? 'green' as const : 'orange' as const,
|
||
disabled: false,
|
||
onClick: () => applyFilters({ ...filters, online: 'online' })
|
||
},
|
||
{
|
||
question: '为什么状态异常?',
|
||
answer: selectedMapRow ? realtimeIssueLabels(selectedMapRow)[0] : `${Math.max(degradedCount, sourceIssueRows.length).toLocaleString()} 辆关注`,
|
||
evidence: selectedMapRow ? vehicleServiceStatus(selectedMapRow).label : `${degradedCount.toLocaleString()} 降级 / ${staleCount.toLocaleString()} 超时`,
|
||
action: '异常清单',
|
||
color: degradedCount > 0 || staleCount > 0 || sourceIssueRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
disabled: false,
|
||
onClick: () => copyRealtimeIssueChecklist()
|
||
},
|
||
{
|
||
question: '当前状态能交接吗?',
|
||
answer: realtimeImpactLevel,
|
||
evidence: `当前页 ${rows.length.toLocaleString()} / 总计 ${pagination.total.toLocaleString()}`,
|
||
action: '复制交接包',
|
||
color: realtimeImpactColor,
|
||
disabled: false,
|
||
onClick: () => copyRealtimeDutyHandoff()
|
||
}
|
||
];
|
||
const timeWindowMonitorBlock = (
|
||
<Card bordered className="vp-time-window-monitor">
|
||
<div className="vp-time-window-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">自定义时间窗复盘</Tag>
|
||
<Tag color={timeWindowReady ? 'green' : 'orange'}>{timeWindowReady ? '时间窗可用' : '先选车辆'}</Tag>
|
||
<Tag color="grey">{timeWindowDuration}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>按车辆和时间窗复盘发生了什么</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
客户问某辆车、某段时间发生了什么时,直接串起轨迹回放、历史数据、统计查询和告警通知。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Button size="small" theme="solid" type="primary" disabled={!timeWindowReady} onClick={openTimeWindowHistory}>轨迹回放</Button>
|
||
<Button size="small" theme="light" type="primary" disabled={!timeWindowReady} onClick={openTimeWindowRaw}>历史数据</Button>
|
||
<Button size="small" theme="light" icon={<IconCopy />} onClick={copyTimeWindowPackage}>复制时间窗包</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-time-window-main">
|
||
<div className="vp-time-window-controls">
|
||
<div className="vp-time-window-target">
|
||
<span>监控车辆</span>
|
||
<strong>{timeWindowRow ? [timeWindowRow.plate, timeWindowRow.vin].filter(Boolean).join(' / ') : timeWindowKeyword || '未选择车辆'}</strong>
|
||
<small>{timeWindowProtocol || '全部数据通道'}</small>
|
||
</div>
|
||
<label>
|
||
<span>开始时间</span>
|
||
<input
|
||
aria-label="时间窗开始"
|
||
value={timeWindow.dateFrom}
|
||
onChange={(event) => setTimeWindow((current) => ({ ...current, dateFrom: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>结束时间</span>
|
||
<input
|
||
aria-label="时间窗结束"
|
||
value={timeWindow.dateTo}
|
||
onChange={(event) => setTimeWindow((current) => ({ ...current, dateTo: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<div className="vp-time-window-presets">
|
||
<Button size="small" onClick={() => setTimeWindowPreset('15m')}>15分钟</Button>
|
||
<Button size="small" onClick={() => setTimeWindowPreset('1h')}>1小时</Button>
|
||
<Button size="small" onClick={() => setTimeWindowPreset('today')}>今天</Button>
|
||
<Button size="small" onClick={() => setTimeWindowPreset('24h')}>近24小时</Button>
|
||
</div>
|
||
</div>
|
||
<div className="vp-time-window-actions">
|
||
{timeWindowWorkItems.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-time-window-action"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`时间窗复盘 ${item.title} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
);
|
||
|
||
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-service-rail">
|
||
<div className="vp-map-service-rail-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">地图服务行动条</Tag>
|
||
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? 'Live Map 可用' : '坐标预览'}</Tag>
|
||
<Tag color={mapAttentionRows.length > 0 ? 'orange' : 'green'}>{mapAttentionRows.length.toLocaleString()} 辆关注</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>把 Live Map 当作客户服务入口:先看在线车辆和关注车辆,再进入路线回放、统计查询和证据导出。</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
地图页的第一动作要围绕车辆服务,而不是围绕协议来源;客户从地图进入监控、复盘、统计和交付。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-map-service-rail-actions">
|
||
{mapServiceActionItems.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-map-service-rail-action"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`地图服务行动条 ${item.title} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="vp-map-decision-board">
|
||
<div className="vp-map-decision-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">地图客户决策</Tag>
|
||
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德地图可用' : '坐标预览'}</Tag>
|
||
<Tag color={mapAttentionRows.length > 0 ? 'orange' : 'green'}>{mapAttentionRows.length.toLocaleString()} 辆关注</Tag>
|
||
</Space>
|
||
<Typography.Text strong>先判断车辆是否在线、是否有位置、是否有异常,再进入单车轨迹和历史查询。</Typography.Text>
|
||
<Typography.Text type="secondary">
|
||
地图页面向客户展示车辆服务状态,数据通道只作为定位、在线和异常追溯依据。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Button size="small" theme="solid" type="primary" onClick={() => applyFilters({ ...filters, online: 'online' })}>查看在线车辆</Button>
|
||
<Button size="small" theme="light" type="primary" icon={<IconCopy />} onClick={copyMapCustomerDecision}>复制地图决策说明</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-map-decision-grid">
|
||
{mapCustomerDecisionItems.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-map-decision-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`地图客户决策 ${item.label} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="vp-map-field-command">
|
||
<div className="vp-map-field-command-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">现场指挥建议</Tag>
|
||
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '地图可用' : '坐标预览'}</Tag>
|
||
<Tag color={mapAttentionRows.length > 0 ? 'orange' : 'green'}>
|
||
{mapAttentionRows.length > 0 ? '先处置关注车辆' : '态势稳定'}
|
||
</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>先看车在哪里,再决定回放、告警和导出</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
现场调度按实时地图、关注车辆、轨迹复盘、通知证据四步工作;围栏和告警后续也应从这里进入。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-map-field-command-grid">
|
||
{mapFieldCommandItems.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-map-field-command-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`地图现场指挥 ${item.title} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="vp-map-dispatch-actions">
|
||
<div className="vp-map-dispatch-actions-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">地图调度处置栏</Tag>
|
||
<Tag color={mapAttentionRows.length > 0 ? 'orange' : 'green'}>{mapAttentionRows.length.toLocaleString()} 辆关注</Tag>
|
||
<Tag color={selectedMapRow ? 'green' : 'grey'}>{selectedMapRow ? '已选车辆' : '待选车辆'}</Tag>
|
||
</Space>
|
||
<Typography.Text strong>把地图上的车辆直接转成调度动作:先处理关注车辆,再复盘选中车辆,最后导出交接证据。</Typography.Text>
|
||
</div>
|
||
<div className="vp-map-dispatch-actions-grid">
|
||
{mapDispatchActionItems.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-map-dispatch-action"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`地图调度处置栏 ${item.title} ${item.value} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</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-selected-service-strip">
|
||
<div className="vp-map-selected-service-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">选中车辆服务</Tag>
|
||
{selectedMapRow ? (
|
||
<>
|
||
<Tag color={vehicleServiceStatus(selectedMapRow).color}>{vehicleServiceStatus(selectedMapRow).label}</Tag>
|
||
<Tag color={dataFreshness(selectedMapRow).color}>{dataFreshness(selectedMapRow).detail}</Tag>
|
||
</>
|
||
) : (
|
||
<Tag color="grey">等待选车</Tag>
|
||
)}
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>{selectedVehicleLabel}</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
客户在地图上选中车辆后,直接进入车辆档案、轨迹回放、统计查询、数据导出和告警处置。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-map-selected-service-actions">
|
||
{mapVehicleWorkItems.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-map-selected-service-action"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`地图选中车辆 ${item.title} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.action}</strong>
|
||
<span>{item.value}</span>
|
||
</button>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className="vp-map-selected-service-action"
|
||
disabled={!selectedMapRow || !canOpenVehicle(selectedMapRow.vin)}
|
||
onClick={openSelectedVehicleRaw}
|
||
aria-label="地图选中车辆 数据导出 历史导出"
|
||
>
|
||
<Tag color="blue">数据导出</Tag>
|
||
<strong>历史导出</strong>
|
||
<span>{selectedMapRow ? selectedVehicleProtocol || selectedMapRow.primaryProtocol || '全部通道' : '等待选择'}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="vp-map-task-strip">
|
||
{mapCustomerTasks.map((item) => (
|
||
<div key={item.title} className="vp-map-task-item">
|
||
<div className="vp-map-task-head">
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
</div>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
<Space wrap>
|
||
<Button
|
||
size="small"
|
||
theme="solid"
|
||
type="primary"
|
||
disabled={item.disabled}
|
||
aria-label={`地图监控任务 ${item.title} ${item.primaryAction}`}
|
||
onClick={item.onPrimary}
|
||
>
|
||
{item.primaryAction}
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
theme="light"
|
||
type="primary"
|
||
disabled={item.disabled || item.secondaryDisabled}
|
||
aria-label={`地图监控任务 ${item.title} ${item.secondaryAction}`}
|
||
onClick={item.onSecondary}
|
||
>
|
||
{item.secondaryAction}
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="vp-map-customer-package">
|
||
<div className="vp-map-customer-package-summary">
|
||
<Typography.Text strong>客户地图监控包</Typography.Text>
|
||
<Space wrap>
|
||
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德地图可用' : '坐标预览'}</Tag>
|
||
<Tag color={mapAttentionRows.length > 0 ? 'orange' : 'green'}>
|
||
{mapAttentionRows.length > 0 ? '存在关注车辆' : '地图态势稳定'}
|
||
</Tag>
|
||
</Space>
|
||
<Typography.Text strong>{selectedVehicleLabel}</Typography.Text>
|
||
<Typography.Text type="secondary">
|
||
面向客户看图时,先确认车辆范围、在线态势、定位态势和选中车辆,再进入轨迹、统计查询、历史数据或告警通知。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Button size="small" theme="solid" type="primary" icon={<IconCopy />} onClick={copyMapCustomerPackage}>复制地图监控包</Button>
|
||
<Button size="small" disabled={!selectedMapRow || !canOpenVehicle(selectedMapRow.vin)} onClick={() => selectedMapRow && onOpenHistory?.(selectedMapRow.vin, selectedVehicleProtocol)}>轨迹回放</Button>
|
||
<Button size="small" disabled={!selectedMapRow || !canOpenVehicle(selectedMapRow.vin)} onClick={() => selectedMapRow && onOpenVehicle(selectedMapRow.vin, selectedVehicleProtocol)}>车辆服务</Button>
|
||
<Button size="small" disabled={!selectedMapRow || !canOpenVehicle(selectedMapRow.vin)} onClick={openSelectedVehicleRaw}>历史数据</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-map-customer-package-grid">
|
||
{mapCustomerPackageItems.map((item) => (
|
||
<div key={item.label} className="vp-map-customer-package-item">
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{timeWindowMonitorBlock}
|
||
|
||
<div className="vp-map-vehicle-workbench">
|
||
<div className="vp-map-vehicle-workbench-head">
|
||
<div>
|
||
<Typography.Text strong>地图车辆作业台</Typography.Text>
|
||
<Typography.Text type="tertiary" size="small">
|
||
当前车辆:{selectedVehicleLabel}
|
||
</Typography.Text>
|
||
</div>
|
||
<Space wrap>
|
||
{selectedMapRow ? (
|
||
<>
|
||
<Tag color={vehicleServiceStatus(selectedMapRow).color}>{vehicleServiceStatus(selectedMapRow).label}</Tag>
|
||
<Tag color={dataFreshness(selectedMapRow).color}>{dataFreshness(selectedMapRow).detail}</Tag>
|
||
<Tag color="blue">{selectedVehicleProtocol || '未知通道'}</Tag>
|
||
</>
|
||
) : (
|
||
<Tag color="grey">先选车辆</Tag>
|
||
)}
|
||
</Space>
|
||
</div>
|
||
<div className="vp-map-vehicle-work-grid">
|
||
{mapVehicleWorkItems.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-map-vehicle-work-item"
|
||
disabled={item.disabled}
|
||
aria-label={`地图车辆作业 ${item.title} ${item.action}`}
|
||
onClick={item.onClick}
|
||
>
|
||
<span className="vp-map-vehicle-work-title">
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
</span>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</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">
|
||
<PageHeader title={title} description={description} />
|
||
<Card bordered className="vp-realtime-customer-board" bodyStyle={{ padding: 0 }}>
|
||
<div className="vp-realtime-customer-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">客户实时监控</Tag>
|
||
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德地图可用' : '坐标预览'}</Tag>
|
||
<Tag color={degradedCount > 0 || staleCount > 0 ? 'orange' : 'green'}>{degradedCount.toLocaleString()} 辆降级 / {staleCount.toLocaleString()} 辆超时</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>从实时列表直接完成车辆监控闭环</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
先筛在线车辆,再看地图位置,异常车辆优先处置,最后进入单车轨迹、统计查询、告警或导出当前页。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Button size="small" theme="solid" type="primary" onClick={() => applyFilters({ ...filters, online: 'online' })}>只看在线</Button>
|
||
<Button size="small" theme="light" type="primary" onClick={() => { window.location.hash = buildAppHash({ page: 'map', protocol: filters.protocol, filters }); }}>打开地图</Button>
|
||
<Button size="small" theme="light" type="warning" onClick={() => applyFilters({ ...filters, serviceStatus: 'degraded' })}>关注异常</Button>
|
||
<Button size="small" theme="light" onClick={copyRealtimeSummary}>复制监控摘要</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-realtime-question-board">
|
||
<div className="vp-realtime-question-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">实时客户常问</Tag>
|
||
<Tag color={realtimeImpactColor}>{realtimeImpactLevel}</Tag>
|
||
<Tag color={selectedMapRow ? 'green' : 'grey'}>{selectedMapRow ? '已选车辆' : '未选车辆'}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>把实时数据翻译成客户能直接理解的车辆状态</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
客户问实时状态时,先回答位置、在线、新鲜度和异常原因,再进入轨迹、告警、历史查询或交接包。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-realtime-question-grid">
|
||
{realtimeCustomerQuestions.map((item) => (
|
||
<button
|
||
key={item.question}
|
||
type="button"
|
||
className="vp-realtime-question-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`实时客户常问 ${item.question} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.question}</Tag>
|
||
<strong>{item.answer}</strong>
|
||
<span>{item.evidence}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="vp-realtime-next-actions">
|
||
{realtimeNextActions.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-realtime-next-action"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`实时运营建议 ${item.title} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.level}</Tag>
|
||
<strong>{item.title}</strong>
|
||
<span>{item.value}</span>
|
||
<small>{item.detail}</small>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="vp-realtime-map-dispatch">
|
||
<div className="vp-realtime-map-dispatch-copy">
|
||
<Space wrap>
|
||
<Tag color="blue">实时地图调度台</Tag>
|
||
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德地图可用' : '坐标预览'}</Tag>
|
||
<Tag color={mapAttentionRows.length > 0 ? 'orange' : 'green'}>{mapAttentionRows.length.toLocaleString()} 辆关注</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>像现代车队 Live Map 一样,把地图作为调度指挥台:先看在线位置,再处理轨迹、告警和导出。</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
协议来源只作为车辆位置可信度证据,客户主路径是找车、调度、复盘和通知。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-realtime-map-dispatch-grid">
|
||
{realtimeMapDispatchItems.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-realtime-map-dispatch-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`实时地图调度台 ${item.title} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="vp-realtime-customer-steps">
|
||
{realtimeCustomerJourneyItems.map((item) => (
|
||
<button
|
||
key={item.step}
|
||
type="button"
|
||
className="vp-realtime-customer-step"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`实时监控路径 ${item.title} ${item.action}`}
|
||
>
|
||
<span>{item.step}</span>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<small>{item.detail}</small>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
{timeWindowMonitorBlock}
|
||
<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">
|
||
地图用于观察车辆在线、定位新鲜度和异常车辆分布;内部接入状态由系统运维页单独承接。
|
||
</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>
|
||
);
|
||
}
|