302 lines
14 KiB
TypeScript
302 lines
14 KiB
TypeScript
import { Button, Card, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||
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 { isAMapConfigured } from '../config/appConfig';
|
||
|
||
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 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 isValidCoordinate(row: VehicleRealtimeRow) {
|
||
return Number.isFinite(row.longitude) && Number.isFinite(row.latitude) && row.longitude !== 0 && row.latitude !== 0;
|
||
}
|
||
|
||
const onlineLabel: Record<string, string> = {
|
||
online: '在线',
|
||
offline: '离线'
|
||
};
|
||
|
||
const serviceStatusLabel: Record<string, string> = {
|
||
healthy: '服务正常',
|
||
degraded: '来源不完整',
|
||
offline: '车辆离线',
|
||
identity_required: '身份未绑定'
|
||
};
|
||
|
||
export function Realtime({
|
||
onOpenVehicle,
|
||
onFiltersChange,
|
||
initialFilters = {}
|
||
}: {
|
||
onOpenVehicle: (vin: string, protocol?: 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 amapConfigured = isAMapConfigured();
|
||
|
||
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 filterSummary = [
|
||
filters.keyword ? `关键词:${filters.keyword}` : '',
|
||
filters.protocol ? `数据来源:${filters.protocol}` : '',
|
||
filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '',
|
||
filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : ''
|
||
].filter(Boolean);
|
||
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 primaryProtocols = new Set(rows.map((row) => row.primaryProtocol).filter(Boolean));
|
||
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 || ''}`
|
||
}));
|
||
|
||
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>
|
||
</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-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>
|
||
<Button size="small" onClick={() => onOpenVehicle(row.vin, filters.protocol || row.primaryProtocol)}>地图车辆服务</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Tabs type="line">
|
||
<Tabs.TabPane tab="表格视图" itemKey="table">
|
||
{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: 110,
|
||
render: (_: unknown, row: VehicleRealtimeRow) => (
|
||
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, filters.protocol || row.primaryProtocol)}>车辆服务</Button>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
)}
|
||
</Tabs.TabPane>
|
||
<Tabs.TabPane tab="地图视图" itemKey="map">
|
||
<VehicleMap points={mapPoints} heightClassName="" fallbackLabel="高德地图未配置,显示实时坐标预览" />
|
||
</Tabs.TabPane>
|
||
</Tabs>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|