Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/Realtime.tsx

560 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}