560 lines
26 KiB
TypeScript
560 lines
26 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 { 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 { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
||
|
||
function canOpenVehicle(vin?: string) {
|
||
const value = vin?.trim();
|
||
return Boolean(value && value !== 'unknown');
|
||
}
|
||
|
||
function vehicleServiceStatus(row: VehicleRealtimeRow) {
|
||
if (row.serviceStatus) {
|
||
return {
|
||
label: row.serviceStatus.title,
|
||
color: row.serviceStatus.severity === 'ok' ? 'green' as const : row.serviceStatus.severity === 'error' ? 'red' as const : 'orange' as const
|
||
};
|
||
}
|
||
if (row.onlineSourceCount <= 0) {
|
||
return { label: '车辆离线', color: 'red' as const };
|
||
}
|
||
if (row.onlineSourceCount < row.sourceCount) {
|
||
return { label: '来源不完整', color: 'orange' as const };
|
||
}
|
||
return { label: '服务正常', color: 'green' as const };
|
||
}
|
||
|
||
function sourceEvidenceText(row: VehicleRealtimeRow) {
|
||
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
|
||
}
|
||
|
||
function formatPercent(value: number) {
|
||
return Number.isFinite(value) ? `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%` : '0%';
|
||
}
|
||
|
||
function serviceStatusWeight(row: VehicleRealtimeRow) {
|
||
const severity = row.serviceStatus?.severity;
|
||
if (severity === 'error') return 4;
|
||
if (severity === 'warning') return 3;
|
||
if (row.onlineSourceCount <= 0) return 4;
|
||
if (row.onlineSourceCount < row.sourceCount) return 3;
|
||
return 1;
|
||
}
|
||
|
||
function sourceIssueTags(row: VehicleRealtimeRow) {
|
||
const tags = (row.sourceStatus ?? [])
|
||
.filter((source) => !source.online || !source.hasRealtime)
|
||
.map((source) => `${source.protocol} ${source.hasRealtime ? '离线' : '未接入'}`) ?? [];
|
||
if (tags.length > 0) return tags;
|
||
if (row.onlineSourceCount < row.sourceCount) return ['来源缺失'];
|
||
return [];
|
||
}
|
||
|
||
function hasSourceIssue(row: VehicleRealtimeRow) {
|
||
const severity = row.serviceStatus?.severity;
|
||
return severity === 'warning' || severity === 'error' || row.onlineSourceCount < row.sourceCount || sourceIssueTags(row).length > 0;
|
||
}
|
||
|
||
function isValidCoordinate(row: VehicleRealtimeRow) {
|
||
return Number.isFinite(row.longitude) && Number.isFinite(row.latitude) && row.longitude !== 0 && row.latitude !== 0;
|
||
}
|
||
|
||
function 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 }
|
||
];
|
||
|
||
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');
|
||
}
|
||
|
||
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({
|
||
onOpenVehicle,
|
||
onOpenHistory,
|
||
onOpenQuality,
|
||
onFiltersChange,
|
||
initialFilters = {}
|
||
}: {
|
||
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 amapConfig = getAMapConfig();
|
||
const amapConfigured = isAMapConfigured(amapConfig);
|
||
|
||
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);
|
||
api.vehicleRealtime(params)
|
||
.then((nextPage) => {
|
||
setRows(nextPage.items);
|
||
setPagination({ currentPage: page, pageSize, total: nextPage.total });
|
||
})
|
||
.catch((error: Error) => Toast.error(error.message))
|
||
.finally(() => setLoading(false));
|
||
};
|
||
|
||
useEffect(() => {
|
||
setFilters(initialFilters);
|
||
load(initialFilters, 1, pagination.pageSize);
|
||
}, [JSON.stringify(initialFilters)]);
|
||
|
||
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 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 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 mapPoints: VehicleMapPoint[] = rows.map((row, index) => ({
|
||
id: row.vin || `${row.primaryProtocol}-${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: '安全代理',
|
||
value: amapConfig.securityServiceHost || '未启用',
|
||
color: amapConfig.securityServiceHost ? 'green' as const : 'grey' as const,
|
||
detail: amapConfig.securityServiceHost ? '安全密钥由服务端追加,不下发到浏览器。' : '未配置代理时会退回前端安全码模式。'
|
||
},
|
||
{
|
||
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
|
||
}), '实时摘要');
|
||
|
||
return (
|
||
<div className="vp-page">
|
||
<PageHeader title="实时监控" description="以车辆为主对象查看最新位置、在线来源、核心实时数据和地图作业状态" />
|
||
<Card bordered>
|
||
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
||
const nextFilters = values as Record<string, string>;
|
||
applyFilters(nextFilters);
|
||
}} style={{ marginBottom: 12 }}>
|
||
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 260 }} />
|
||
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
|
||
<Select.Option value="GB32960">GB32960</Select.Option>
|
||
<Select.Option value="JT808">JT808</Select.Option>
|
||
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="online" label="在线" placeholder="全部" style={{ width: 130 }}>
|
||
<Select.Option value="online">在线</Select.Option>
|
||
<Select.Option value="offline">离线</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="serviceStatus" label="服务状态" placeholder="全部" style={{ width: 150 }}>
|
||
<Select.Option value="healthy">服务正常</Select.Option>
|
||
<Select.Option value="degraded">来源不完整</Select.Option>
|
||
<Select.Option value="offline">车辆离线</Select.Option>
|
||
<Select.Option value="identity_required">身份未绑定</Select.Option>
|
||
</Form.Select>
|
||
<Space>
|
||
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
||
<Button onClick={() => {
|
||
applyFilters({});
|
||
}}>重置</Button>
|
||
</Space>
|
||
</Form>
|
||
{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-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>
|
||
</Space>
|
||
</div>
|
||
<VehicleMap
|
||
points={mapPoints}
|
||
maxFallbackPoints={80}
|
||
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: degradedCount.toLocaleString(), color: degradedCount > 0 ? 'orange' as const : 'green' as const },
|
||
{ label: '来源类型', value: primaryProtocols.size.toLocaleString(), color: 'blue' as const }
|
||
].map((item) => (
|
||
<div key={item.label} className="vp-monitor-metric">
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<div className="vp-monitor-metric-value">{item.value}</div>
|
||
</div>
|
||
))}
|
||
<Typography.Text type="secondary">
|
||
高德 Web JS Key 和安全密钥通过运行环境配置,前端只读取公开 Key;安全密钥后续走服务端代理,避免明文下发。
|
||
</Typography.Text>
|
||
<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>
|
||
{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}`} 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>
|
||
<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" 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="实时覆盖判读" 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: 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: 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={!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="" fallbackLabel="高德地图未配置,显示实时坐标预览" />
|
||
</Tabs.TabPane>
|
||
</Tabs>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|