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

511 lines
20 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 { useCallback, useEffect, useState } from 'react';
import { Toast } from '@douyinfe/semi-ui';
import { api } from './api/client';
import type { VehicleSourceConsistency, VehicleServiceOverview, VehicleServiceStatus } from './api/types';
import { buildAppHash, parseAppHash } from './domain/appRoute';
import { AppShell, type PageKey } from './layout/AppShell';
import { Dashboard } from './pages/Dashboard';
import { History } from './pages/History';
import { Mileage } from './pages/Mileage';
import { Quality } from './pages/Quality';
import { Realtime } from './pages/Realtime';
import { VehicleDetail } from './pages/VehicleDetail';
import { Vehicles } from './pages/Vehicles';
export default function App() {
const initialRoute = parseAppHash(window.location.hash);
const initialVehicleKey = initialRoute.keyword || 'LB9A32A24R0LS1426';
const [activePage, setActivePage] = useState<PageKey>(initialRoute.page ?? 'dashboard');
const [activeVin, setActiveVin] = useState(initialVehicleKey);
const [analysisVin, setAnalysisVin] = useState(initialVehicleKey);
const [activeProtocol, setActiveProtocol] = useState(initialRoute.protocol ?? '');
const [historyTab, setHistoryTab] = useState(initialRoute.filters?.tab ?? 'location');
const [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(
initialRoute.page === 'vehicles' ? vehicleFiltersFromRoute(initialRoute) : {}
);
const [realtimeFilters, setRealtimeFilters] = useState<Record<string, string>>(
initialRoute.page === 'realtime' ? realtimeFiltersFromRoute(initialRoute) : {}
);
const [historyFilters, setHistoryFilters] = useState<Record<string, string>>(
initialRoute.page === 'history' ? historyFiltersFromRoute(initialRoute) : {}
);
const [mileageFilters, setMileageFilters] = useState<Record<string, string>>(
initialRoute.page === 'mileage' ? mileageFiltersFromRoute(initialRoute) : {}
);
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
);
const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
const [currentVehicleStatus, setCurrentVehicleStatus] = useState<VehicleServiceStatus | undefined>();
const [currentVehicleLabel, setCurrentVehicleLabel] = useState('');
const [currentVehicleConsistency, setCurrentVehicleConsistency] = useState<VehicleSourceConsistency | undefined>();
const loadVehicleContext = useCallback(async (keyword: string, protocol?: string) => {
const lookupKey = keyword.trim();
if (!lookupKey) {
return undefined;
}
const params = new URLSearchParams({ keyword: lookupKey });
const source = protocol?.trim();
if (source) {
params.set('protocol', source);
}
const overview = await api.vehicleServiceOverview(params);
const nextKey = overview.vin || lookupKey;
const resolved = Boolean(overview.vin);
setCurrentVehicleStatus(overview.serviceStatus ?? serviceStatusFromOverview(overview));
setCurrentVehicleLabel(resolved ? [overview.plate, overview.vin || nextKey].filter(Boolean).join(' / ') : lookupKey);
setCurrentVehicleConsistency(overview.sourceConsistency);
return { resolved, nextKey, overview };
}, []);
const refreshOpsHealth = useCallback((showError = true) => {
return api.opsHealth()
.then((health) => {
setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length);
return health;
})
.catch((error: Error) => {
if (showError) {
Toast.error(error.message);
}
});
}, [loadVehicleContext]);
useEffect(() => {
if (initialRoute.page !== 'detail' || !initialRoute.keyword) {
return;
}
loadVehicleContext(initialRoute.keyword, initialRoute.protocol).catch((error) => {
Toast.error(error instanceof Error ? error.message : '车辆查询失败');
});
}, [initialRoute.keyword, initialRoute.page, loadVehicleContext]);
useEffect(() => {
refreshOpsHealth();
const timer = window.setInterval(() => {
refreshOpsHealth(false);
}, 60000);
return () => window.clearInterval(timer);
}, [refreshOpsHealth]);
useEffect(() => {
const applyHashRoute = () => {
const route = parseAppHash(window.location.hash);
if (!route.page) {
return;
}
setActivePage(route.page);
if (route.keyword) {
if (route.page === 'detail') {
setActiveVin(route.keyword);
}
if (route.page === 'history' || route.page === 'mileage') {
setAnalysisVin(route.keyword);
}
}
if (route.page === 'vehicles') {
setVehicleFilters(vehicleFiltersFromRoute(route));
}
if (route.page === 'realtime') {
setRealtimeFilters(realtimeFiltersFromRoute(route));
}
if (route.page === 'history') {
setHistoryFilters(historyFiltersFromRoute(route));
}
if (route.page === 'mileage') {
setMileageFilters(mileageFiltersFromRoute(route));
}
if (route.page === 'quality') {
setQualityFilters(qualityFiltersFromRoute(route));
}
setActiveProtocol(route.protocol ?? '');
setHistoryTab(route.filters?.tab ?? 'location');
};
window.addEventListener('hashchange', applyHashRoute);
return () => window.removeEventListener('hashchange', applyHashRoute);
}, []);
const replaceHash = (page: PageKey, keyword?: string, protocol?: string, filters?: Record<string, string>) => {
const nextHash = buildAppHash({ page, keyword, protocol, filters });
if (window.location.hash !== nextHash) {
window.history.replaceState(null, '', nextHash);
}
};
const navigatePage = (page: PageKey) => {
setActivePage(page);
if (page === 'detail') {
replaceHash(page, activeVin, activeProtocol);
return;
}
if (page === 'history' || page === 'mileage') {
if (page === 'history') {
replaceHistoryHash(historyFilters);
return;
}
replaceMileageHash(mileageFilters);
return;
}
if (page === 'quality') {
replaceQualityHash(qualityFilters);
return;
}
if (page === 'vehicles') {
replaceVehicleHash(vehicleFilters);
return;
}
if (page === 'realtime') {
replaceRealtimeHash(realtimeFilters);
return;
}
replaceHash(page);
};
const openVehicles = (filters: Record<string, string> = {}) => {
setVehicleFilters(filters);
setActivePage('vehicles');
replaceVehicleHash(filters);
};
const updateVehicleFilters = (filters: Record<string, string> = {}) => {
setVehicleFilters(filters);
replaceVehicleHash(filters);
};
const replaceVehicleHash = (filters: Record<string, string> = {}) => {
const { keyword, protocol, ...restFilters } = filters;
replaceHash('vehicles', keyword, protocol, restFilters);
};
const updateRealtimeFilters = (filters: Record<string, string> = {}) => {
setRealtimeFilters(filters);
replaceRealtimeHash(filters);
};
const replaceRealtimeHash = (filters: Record<string, string> = {}) => {
const { keyword, protocol, ...restFilters } = filters;
replaceHash('realtime', keyword, protocol, restFilters);
};
const updateHistoryFilters = (filters: Record<string, unknown> = {}, tab = historyTab) => {
const nextFilters = normalizeHistoryFilterValues(filters);
setHistoryFilters(nextFilters);
if (nextFilters.keyword) {
setAnalysisVin(nextFilters.keyword);
}
setActiveProtocol(nextFilters.protocol ?? '');
setHistoryTab(tab);
const { keyword, protocol, ...restFilters } = nextFilters;
const routeFilters = { ...restFilters };
if (tab && tab !== 'location') {
routeFilters.tab = tab;
}
replaceHash('history', keyword ?? analysisVin, protocol, routeFilters);
};
const replaceHistoryHash = (filters: Record<string, string> = {}, tab = historyTab) => {
const { keyword, protocol, ...restFilters } = filters;
const routeFilters = { ...restFilters };
if (tab && tab !== 'location') {
routeFilters.tab = tab;
}
replaceHash('history', keyword ?? analysisVin, protocol ?? activeProtocol, routeFilters);
};
const updateMileageFilters = (filters: Record<string, unknown> = {}) => {
const nextFilters = normalizeMileageFilterValues(filters);
setMileageFilters(nextFilters);
if (nextFilters.keyword) {
setAnalysisVin(nextFilters.keyword);
}
setActiveProtocol(nextFilters.protocol ?? '');
const { keyword, protocol, ...restFilters } = nextFilters;
replaceHash('mileage', keyword ?? analysisVin, protocol, restFilters);
};
const replaceMileageHash = (filters: Record<string, string> = {}) => {
const { keyword, protocol, ...restFilters } = filters;
replaceHash('mileage', keyword ?? analysisVin, protocol ?? activeProtocol, restFilters);
};
const replaceQualityHash = (filters: Record<string, string> = {}) => {
const keyword = filters.keyword;
const protocol = filters.protocol;
const issueFilters: Record<string, string> = {};
if (filters.issueType) {
issueFilters.issueType = filters.issueType;
}
replaceHash('quality', keyword, protocol, issueFilters);
};
const updateQualityFilters = (filters: Record<string, string> = {}) => {
setQualityFilters(filters);
replaceQualityHash(filters);
};
const openQuality = (filters: Record<string, string> = {}) => {
setQualityFilters(filters);
setActivePage('quality');
replaceQualityHash(filters);
};
const openRealtime = (filters: Record<string, string> = {}) => {
setRealtimeFilters(filters);
setActivePage('realtime');
replaceRealtimeHash(filters);
};
const openVehicle = async (keyword: string, protocol?: string) => {
const lookupKey = keyword.trim();
const nextProtocol = protocol?.trim() ?? '';
if (!lookupKey) {
return;
}
try {
const context = await loadVehicleContext(lookupKey, nextProtocol);
const resolved = context?.resolved;
const nextKey = context?.nextKey ?? lookupKey;
setActiveVin(nextKey);
setActiveProtocol(nextProtocol);
setActivePage('detail');
replaceHash('detail', nextKey, nextProtocol);
if (!resolved) {
Toast.warning('未匹配到车辆身份,已打开问题排查视图');
}
} catch (error) {
Toast.error(error instanceof Error ? error.message : '车辆查询失败');
}
};
const openHistoryForVehicle = (vin: string, protocol?: string) => {
const nextVin = vin.trim();
const nextProtocol = protocol?.trim() ?? activeProtocol;
if (!nextVin) {
return;
}
setAnalysisVin(nextVin);
setActiveProtocol(nextProtocol);
setHistoryTab('location');
setHistoryFilters({ keyword: nextVin, protocol: nextProtocol });
setActivePage('history');
replaceHash('history', nextVin, nextProtocol);
};
const openHistoryWithFilters = (filters: Record<string, string> = {}) => {
const nextFilters = normalizeHistoryFilterValues(filters);
const nextVin = nextFilters.keyword?.trim() || analysisVin;
const nextProtocol = nextFilters.protocol?.trim() ?? activeProtocol;
setAnalysisVin(nextVin);
setActiveProtocol(nextProtocol);
setHistoryTab('location');
setHistoryFilters({ ...nextFilters, keyword: nextVin, protocol: nextProtocol });
setActivePage('history');
const { keyword, protocol, ...restFilters } = nextFilters;
replaceHash('history', keyword ?? nextVin, protocol ?? nextProtocol, restFilters);
};
const openRawForVehicle = (vin: string, protocol?: string) => {
const nextVin = vin.trim();
const nextProtocol = protocol?.trim() ?? activeProtocol;
if (!nextVin) {
return;
}
setAnalysisVin(nextVin);
setActiveProtocol(nextProtocol);
setHistoryTab('raw');
setHistoryFilters({ keyword: nextVin, protocol: nextProtocol });
setActivePage('history');
replaceHash('history', nextVin, nextProtocol, { tab: 'raw' });
};
const openRealtimeForVehicle = (vin: string, protocol?: string) => {
const nextVin = vin.trim();
const nextProtocol = protocol?.trim() ?? activeProtocol;
if (!nextVin) {
return;
}
const nextFilters = { keyword: nextVin, protocol: nextProtocol };
setAnalysisVin(nextVin);
setActiveProtocol(nextProtocol);
setRealtimeFilters(nextFilters);
setActivePage('realtime');
replaceRealtimeHash(nextFilters);
};
const openMileageForVehicle = (vin: string, protocol?: string) => {
const nextVin = vin.trim();
const nextProtocol = protocol?.trim() ?? activeProtocol;
if (!nextVin) {
return;
}
setAnalysisVin(nextVin);
setActiveProtocol(nextProtocol);
setMileageFilters({ keyword: nextVin, protocol: nextProtocol });
setActivePage('mileage');
replaceHash('mileage', nextVin, nextProtocol);
};
const updateVehicleDetailQuery = (keyword: string, protocol?: string) => {
const nextKeyword = keyword.trim();
const nextProtocol = protocol?.trim() ?? '';
if (!nextKeyword) {
return;
}
setActiveVin(nextKeyword);
setActiveProtocol(nextProtocol);
replaceHash('detail', nextKeyword, nextProtocol);
};
const pages: Record<PageKey, JSX.Element> = {
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={openQuality} onOpenRealtime={openRealtime} onOpenVehicles={openVehicles} />,
vehicles: <Vehicles onOpenVehicle={openVehicle} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
realtime: <Realtime onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onOpenQuality={openQuality} onQueryChange={updateVehicleDetailQuery} />,
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} />,
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} initialFilters={mileageFilters} onFiltersChange={updateMileageFilters} onOpenVehicle={openVehicle} onOpenHistory={openHistoryWithFilters} />,
quality: <Quality onOpenVehicle={openVehicle} onOpenHistory={openHistoryWithFilters} onHealthLoaded={(health) => setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />
};
return (
<AppShell activePage={activePage} linkIssueCount={linkIssueCount} currentVehicleStatus={currentVehicleStatus} currentVehicleLabel={currentVehicleLabel} currentVehicleConsistency={currentVehicleConsistency} onChange={navigatePage} onVehicleSearch={openVehicle}>
{pages[activePage]}
</AppShell>
);
}
function qualityFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
return {
...(route.keyword ? { keyword: route.keyword } : {}),
...(route.protocol ? { protocol: route.protocol } : {}),
...(route.filters?.issueType ? { issueType: route.filters.issueType } : {})
};
}
function vehicleFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
return {
...(route.keyword ? { keyword: route.keyword } : {}),
...(route.protocol ? { protocol: route.protocol } : {}),
...(route.filters ?? {})
};
}
function realtimeFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
return {
...(route.keyword ? { keyword: route.keyword } : {}),
...(route.protocol ? { protocol: route.protocol } : {}),
...(route.filters?.online ? { online: route.filters.online } : {}),
...(route.filters?.serviceStatus ? { serviceStatus: route.filters.serviceStatus } : {})
};
}
function historyFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
return normalizeHistoryFilterValues({
...(route.keyword ? { keyword: route.keyword } : {}),
...(route.protocol ? { protocol: route.protocol } : {}),
...(route.filters?.dateFrom ? { dateFrom: route.filters.dateFrom } : {}),
...(route.filters?.dateTo ? { dateTo: route.filters.dateTo } : {}),
...(route.filters?.includeFields ? { includeFields: route.filters.includeFields } : {}),
...(route.filters?.fields ? { fields: route.filters.fields } : {})
});
}
function mileageFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
return normalizeMileageFilterValues({
...(route.keyword ? { keyword: route.keyword } : {}),
...(route.protocol ? { protocol: route.protocol } : {}),
...(route.filters?.dateFrom ? { dateFrom: route.filters.dateFrom } : {}),
...(route.filters?.dateTo ? { dateTo: route.filters.dateTo } : {})
});
}
function normalizeMileageFilterValues(filters: Record<string, unknown> = {}): Record<string, string> {
const normalized: Record<string, string> = {};
for (const key of ['keyword', 'protocol', 'dateFrom', 'dateTo'] as const) {
const value = String(filters[key] ?? '').trim();
if (value) {
normalized[key] = value;
}
}
return normalized;
}
function normalizeHistoryFilterValues(filters: Record<string, unknown> = {}): Record<string, string> {
const normalized: Record<string, string> = {};
for (const key of ['keyword', 'protocol', 'dateFrom', 'dateTo', 'fields'] as const) {
const value = String(filters[key] ?? '').trim();
if (value) {
normalized[key] = value;
}
}
const includeFields = filters.includeFields;
if (includeFields === true || includeFields === 'true') {
normalized.includeFields = 'true';
}
return normalized;
}
function serviceStatusFromOverview(overview: VehicleServiceOverview): VehicleServiceStatus {
const sourceCount = overview.sourceCount;
const onlineSourceCount = overview.onlineSourceCount;
const missingProtocols = overview.sourceConsistency?.missingProtocols ?? [];
if (!overview.vin) {
return {
status: 'identity_required',
severity: 'warning',
title: '身份未绑定',
detail: '车辆关键词暂未解析到 VIN需先维护身份绑定后才能形成完整车辆服务。',
sourceCount,
onlineSourceCount
};
}
if (sourceCount <= 0) {
return {
status: 'no_data',
severity: 'warning',
title: '暂无数据来源',
detail: '车辆已解析,但暂未查询到 32960、808 或 MQTT 数据来源。',
sourceCount,
onlineSourceCount
};
}
if (onlineSourceCount <= 0) {
return {
status: 'offline',
severity: 'error',
title: '车辆离线',
detail: '所有已知数据来源均未在线,需要检查平台转发、终端上报或链路状态。',
sourceCount,
onlineSourceCount
};
}
if (missingProtocols.length > 0) {
return {
status: 'degraded',
severity: 'warning',
title: '来源不完整',
detail: `${onlineSourceCount}/${sourceCount} 个来源在线,车辆服务可用但缺少 ${missingProtocols.join('、')} 来源。`,
sourceCount,
onlineSourceCount
};
}
if (onlineSourceCount < sourceCount) {
return {
status: 'degraded',
severity: 'warning',
title: '来源不完整',
detail: `${onlineSourceCount}/${sourceCount} 个来源在线,车辆服务可用但需要关注离线来源。`,
sourceCount,
onlineSourceCount
};
}
return {
status: 'healthy',
severity: 'ok',
title: '服务正常',
detail: `${onlineSourceCount}/${sourceCount} 个来源在线,全部已知来源在线。`,
sourceCount,
onlineSourceCount
};
}