253 lines
9.5 KiB
TypeScript
253 lines
9.5 KiB
TypeScript
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 [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(initialRoute.filters ?? {});
|
||
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) => {
|
||
const lookupKey = keyword.trim();
|
||
if (!lookupKey) {
|
||
return undefined;
|
||
}
|
||
const overview = await api.vehicleServiceOverview(new URLSearchParams({ keyword: lookupKey }));
|
||
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).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(route.filters ?? {});
|
||
}
|
||
setActiveProtocol(route.protocol ?? '');
|
||
};
|
||
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') {
|
||
replaceHash(page, analysisVin, activeProtocol);
|
||
return;
|
||
}
|
||
replaceHash(page, undefined, undefined, page === 'vehicles' ? vehicleFilters : undefined);
|
||
};
|
||
|
||
const openVehicles = (filters: Record<string, string> = {}) => {
|
||
setVehicleFilters(filters);
|
||
setActivePage('vehicles');
|
||
replaceHash('vehicles', undefined, undefined, filters);
|
||
};
|
||
|
||
const updateVehicleFilters = (filters: Record<string, string> = {}) => {
|
||
setVehicleFilters(filters);
|
||
replaceHash('vehicles', undefined, undefined, 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);
|
||
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);
|
||
setActivePage('history');
|
||
replaceHash('history', nextVin, nextProtocol);
|
||
};
|
||
|
||
const openMileageForVehicle = (vin: string, protocol?: string) => {
|
||
const nextVin = vin.trim();
|
||
const nextProtocol = protocol?.trim() ?? activeProtocol;
|
||
if (!nextVin) {
|
||
return;
|
||
}
|
||
setAnalysisVin(nextVin);
|
||
setActiveProtocol(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={() => navigatePage('quality')} onOpenVehicles={openVehicles} />,
|
||
vehicles: <Vehicles onOpenVehicle={openVehicle} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
||
realtime: <Realtime onOpenVehicle={openVehicle} />,
|
||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenMileage={openMileageForVehicle} onQueryChange={updateVehicleDetailQuery} />,
|
||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
||
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
||
quality: <Quality onOpenVehicle={openVehicle} onHealthLoaded={(health) => setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} />
|
||
};
|
||
|
||
return (
|
||
<AppShell activePage={activePage} linkIssueCount={linkIssueCount} currentVehicleStatus={currentVehicleStatus} currentVehicleLabel={currentVehicleLabel} currentVehicleConsistency={currentVehicleConsistency} onChange={navigatePage} onVehicleSearch={openVehicle}>
|
||
{pages[activePage]}
|
||
</AppShell>
|
||
);
|
||
}
|
||
|
||
function serviceStatusFromOverview(overview: VehicleServiceOverview): VehicleServiceStatus {
|
||
const sourceCount = overview.sourceCount;
|
||
const onlineSourceCount = overview.onlineSourceCount;
|
||
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 (onlineSourceCount < sourceCount) {
|
||
return {
|
||
status: 'degraded',
|
||
severity: 'warning',
|
||
title: '部分来源离线',
|
||
detail: `${onlineSourceCount}/${sourceCount} 个来源在线,车辆服务可用但需要关注离线来源。`,
|
||
sourceCount,
|
||
onlineSourceCount
|
||
};
|
||
}
|
||
return {
|
||
status: 'healthy',
|
||
severity: 'ok',
|
||
title: '服务正常',
|
||
detail: `${onlineSourceCount}/${sourceCount} 个来源在线,全部已知来源在线。`,
|
||
sourceCount,
|
||
onlineSourceCount
|
||
};
|
||
}
|