817 lines
34 KiB
TypeScript
817 lines
34 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { Toast } from '@douyinfe/semi-ui';
|
||
import { api } from './api/client';
|
||
import type { QualityNotificationPlan, VehicleSourceConsistency, VehicleServiceOverview, VehicleServiceStatus } from './api/types';
|
||
import { buildAppHash, parseAppHash } from './domain/appRoute';
|
||
import { AppShell, type CustomerView, type PageKey } from './layout/AppShell';
|
||
import { Dashboard } from './pages/Dashboard';
|
||
import { History } from './pages/History';
|
||
import { Mileage } from './pages/Mileage';
|
||
import { NotificationRules } from './pages/NotificationRules';
|
||
import { OpsQuality } from './pages/OpsQuality';
|
||
import { Quality } from './pages/Quality';
|
||
import { Realtime } from './pages/Realtime';
|
||
import { VehicleDetail } from './pages/VehicleDetail';
|
||
import { Vehicles } from './pages/Vehicles';
|
||
|
||
const customerViewsStorageKey = 'lingniu.customerViews.v1';
|
||
|
||
export default function App() {
|
||
const initialRoute = parseAppHash(window.location.hash);
|
||
const initialVehicleKey = initialRoute.keyword || '';
|
||
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 ?? (initialRoute.page === 'history-query' ? 'raw' : 'location'));
|
||
const [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(
|
||
initialRoute.page === 'vehicles' ? vehicleFiltersFromRoute(initialRoute) : {}
|
||
);
|
||
const [realtimeFilters, setRealtimeFilters] = useState<Record<string, string>>(
|
||
initialRoute.page === 'realtime' || initialRoute.page === 'map' ? realtimeFiltersFromRoute(initialRoute) : {}
|
||
);
|
||
const [historyFilters, setHistoryFilters] = useState<Record<string, string>>(
|
||
initialRoute.page === 'history' || initialRoute.page === 'history-query' ? historyFiltersFromRoute(initialRoute) : {}
|
||
);
|
||
const [mileageFilters, setMileageFilters] = useState<Record<string, string>>(
|
||
initialRoute.page === 'mileage' ? mileageFiltersFromRoute(initialRoute) : {}
|
||
);
|
||
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
||
initialRoute.page === 'quality' || initialRoute.page === 'alert-events' ? qualityFiltersFromRoute(initialRoute) : {}
|
||
);
|
||
const [customerTimeWindow, setCustomerTimeWindow] = useState<Record<string, string>>(timeWindowFiltersFromRoute(initialRoute));
|
||
const [customerViews, setCustomerViews] = useState<CustomerView[]>(readCustomerViews);
|
||
const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
|
||
const [activeAlertRuleCount, setActiveAlertRuleCount] = useState<number | null>(null);
|
||
const [p0AlertRuleCount, setP0AlertRuleCount] = useState<number | null>(null);
|
||
const [platformRelease, setPlatformRelease] = useState('');
|
||
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 applyNotificationPlanSummary = (plan?: QualityNotificationPlan | null) => {
|
||
if (!plan) {
|
||
return;
|
||
}
|
||
const activeCount = Number(plan.activeRuleCount);
|
||
const p0Count = Number(plan.p0RuleCount);
|
||
if (Number.isFinite(activeCount)) {
|
||
setActiveAlertRuleCount(activeCount);
|
||
}
|
||
if (Number.isFinite(p0Count)) {
|
||
setP0AlertRuleCount(p0Count);
|
||
}
|
||
};
|
||
|
||
const refreshOpsHealth = useCallback((showError = true) => {
|
||
return Promise.all([
|
||
api.opsHealth(),
|
||
api.alertEventNotificationPlan(new URLSearchParams({ limit: '5' })).catch(() => null)
|
||
])
|
||
.then(([health, notificationPlan]) => {
|
||
setLinkIssueCount((health.linkHealth ?? []).filter((item) => item.status !== 'ok').length);
|
||
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
||
applyNotificationPlanSummary(notificationPlan);
|
||
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 === 'dashboard' || route.page === 'history' || route.page === 'history-query' || route.page === 'mileage') {
|
||
setAnalysisVin(route.keyword);
|
||
}
|
||
if (route.page === 'time-monitor') {
|
||
setAnalysisVin(route.keyword);
|
||
}
|
||
}
|
||
if (route.page === 'vehicles') {
|
||
setVehicleFilters(vehicleFiltersFromRoute(route));
|
||
}
|
||
if (route.page === 'realtime' || route.page === 'map') {
|
||
setRealtimeFilters(realtimeFiltersFromRoute(route));
|
||
}
|
||
if (route.page === 'history' || route.page === 'history-query') {
|
||
setHistoryFilters(historyFiltersFromRoute(route));
|
||
}
|
||
if (route.page === 'mileage') {
|
||
setMileageFilters(mileageFiltersFromRoute(route));
|
||
}
|
||
if (route.page === 'quality' || route.page === 'alert-events') {
|
||
setQualityFilters(qualityFiltersFromRoute(route));
|
||
}
|
||
const routeTimeWindow = timeWindowFiltersFromRoute(route);
|
||
if (routeTimeWindow.dateFrom || routeTimeWindow.dateTo) {
|
||
setCustomerTimeWindow(routeTimeWindow);
|
||
}
|
||
setActiveProtocol(route.protocol ?? '');
|
||
setHistoryTab(route.filters?.tab ?? (route.page === 'history-query' ? 'raw' : '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 === 'history-query' || page === 'mileage') {
|
||
if (page === 'history') {
|
||
setHistoryTab('location');
|
||
replaceHistoryHash(historyFilters, 'location', 'history');
|
||
return;
|
||
}
|
||
if (page === 'history-query') {
|
||
setHistoryTab('raw');
|
||
replaceHistoryHash(historyFilters, 'raw', 'history-query');
|
||
return;
|
||
}
|
||
replaceMileageHash(mileageFilters);
|
||
return;
|
||
}
|
||
if (page === 'time-monitor') {
|
||
const keyword = (activeVin || analysisVin).trim();
|
||
const protocol = activeProtocol.trim();
|
||
replaceHash('time-monitor', keyword, protocol, customerTimeWindow);
|
||
return;
|
||
}
|
||
if (page === 'quality' || page === 'alert-events') {
|
||
replaceQualityHash(qualityFilters);
|
||
return;
|
||
}
|
||
if (page === 'notification-rules' || page === 'ops-quality') {
|
||
replaceHash(page);
|
||
return;
|
||
}
|
||
if (page === 'vehicles') {
|
||
replaceVehicleHash(vehicleFilters);
|
||
return;
|
||
}
|
||
if (page === 'realtime') {
|
||
replaceRealtimeHash(realtimeFilters);
|
||
return;
|
||
}
|
||
if (page === 'map') {
|
||
replaceMapHash(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 updateMapFilters = (filters: Record<string, string> = {}) => {
|
||
setRealtimeFilters(filters);
|
||
replaceMapHash(filters);
|
||
};
|
||
|
||
const replaceRealtimeHash = (filters: Record<string, string> = {}) => {
|
||
const { keyword, protocol, ...restFilters } = filters;
|
||
replaceHash('realtime', keyword, protocol, restFilters);
|
||
};
|
||
|
||
const replaceMapHash = (filters: Record<string, string> = {}) => {
|
||
const { keyword, protocol, ...restFilters } = filters;
|
||
replaceHash('map', keyword, protocol, restFilters);
|
||
};
|
||
|
||
const updateHistoryFilters = (filters: Record<string, unknown> = {}, tab = historyTab) => {
|
||
const hasKeywordInput = Object.prototype.hasOwnProperty.call(filters, 'keyword');
|
||
const nextFilters = normalizeHistoryFilterValues(filters);
|
||
setCustomerTimeWindow(timeWindowFiltersFromValues(nextFilters));
|
||
setHistoryFilters(nextFilters);
|
||
if (nextFilters.keyword) {
|
||
setAnalysisVin(nextFilters.keyword);
|
||
} else if (hasKeywordInput) {
|
||
setAnalysisVin('');
|
||
}
|
||
setActiveProtocol(nextFilters.protocol ?? '');
|
||
setHistoryTab(tab);
|
||
const { keyword, protocol, ...restFilters } = nextFilters;
|
||
const routeFilters = { ...restFilters };
|
||
if (tab && tab !== 'location') {
|
||
routeFilters.tab = tab;
|
||
}
|
||
replaceHash(activePage === 'history-query' ? 'history-query' : 'history', hasKeywordInput ? keyword : (keyword ?? analysisVin), protocol, routeFilters);
|
||
};
|
||
|
||
const replaceHistoryHash = (filters: Record<string, string> = {}, tab = historyTab, page: 'history' | 'history-query' = activePage === 'history-query' ? 'history-query' : 'history') => {
|
||
const { keyword, protocol, ...restFilters } = filters;
|
||
const routeFilters = { ...restFilters };
|
||
if (tab && tab !== 'location') {
|
||
routeFilters.tab = tab;
|
||
}
|
||
replaceHash(page, keyword ?? analysisVin, protocol ?? activeProtocol, routeFilters);
|
||
};
|
||
|
||
const updateMileageFilters = (filters: Record<string, unknown> = {}) => {
|
||
const nextFilters = normalizeMileageFilterValues(filters);
|
||
setCustomerTimeWindow(timeWindowFiltersFromValues(nextFilters));
|
||
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;
|
||
}
|
||
if (filters.dateFrom) {
|
||
issueFilters.dateFrom = filters.dateFrom;
|
||
}
|
||
if (filters.dateTo) {
|
||
issueFilters.dateTo = filters.dateTo;
|
||
}
|
||
replaceHash('alert-events', keyword, protocol, issueFilters);
|
||
};
|
||
|
||
const updateQualityFilters = (filters: Record<string, string> = {}) => {
|
||
setCustomerTimeWindow(timeWindowFiltersFromValues(filters));
|
||
setQualityFilters(filters);
|
||
replaceQualityHash(filters);
|
||
};
|
||
|
||
const openQuality = (filters: Record<string, string> = {}) => {
|
||
setCustomerTimeWindow(timeWindowFiltersFromValues(filters));
|
||
setQualityFilters(filters);
|
||
setActivePage('alert-events');
|
||
replaceQualityHash(filters);
|
||
};
|
||
|
||
const openRealtime = (filters: Record<string, string> = {}) => {
|
||
setRealtimeFilters(filters);
|
||
setActivePage('realtime');
|
||
replaceRealtimeHash(filters);
|
||
};
|
||
|
||
const openMap = (filters: Record<string, string> = {}) => {
|
||
setRealtimeFilters(filters);
|
||
setActivePage('map');
|
||
replaceMapHash(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;
|
||
const resolvedProtocol = nextProtocol || context?.overview.primaryProtocol?.trim() || '';
|
||
setActiveVin(nextKey);
|
||
setAnalysisVin(nextKey);
|
||
setActiveProtocol(resolvedProtocol);
|
||
setActivePage('detail');
|
||
replaceHash('detail', nextKey, resolvedProtocol);
|
||
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);
|
||
setCustomerTimeWindow(timeWindowFiltersFromValues(nextFilters));
|
||
const hasKeywordInput = Object.prototype.hasOwnProperty.call(nextFilters, 'keyword');
|
||
const nextVin = nextFilters.keyword?.trim() || (hasKeywordInput ? '' : analysisVin);
|
||
const nextProtocol = nextFilters.protocol?.trim() ?? activeProtocol;
|
||
const nextTab = nextFilters.tab === 'raw' || nextFilters.tab === 'fields' ? nextFilters.tab : 'location';
|
||
const nextPage = nextTab === 'raw' || nextTab === 'fields' ? 'history-query' : 'history';
|
||
setAnalysisVin(nextVin);
|
||
setActiveProtocol(nextProtocol);
|
||
setHistoryTab(nextTab);
|
||
setHistoryFilters({ ...nextFilters, keyword: nextVin, protocol: nextProtocol });
|
||
setActivePage(nextPage);
|
||
const { keyword, protocol, ...restFilters } = nextFilters;
|
||
if (nextTab === 'raw') {
|
||
restFilters.tab = nextTab;
|
||
} else {
|
||
delete restFilters.tab;
|
||
}
|
||
replaceHash(nextPage, 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-query');
|
||
replaceHash('history-query', nextVin, nextProtocol, { tab: 'raw' });
|
||
};
|
||
|
||
const openRawWithFilters = (filters: Record<string, string> = {}) => {
|
||
const nextFilters = normalizeHistoryFilterValues(filters);
|
||
setCustomerTimeWindow(timeWindowFiltersFromValues(nextFilters));
|
||
const hasKeywordInput = Object.prototype.hasOwnProperty.call(nextFilters, 'keyword');
|
||
const nextVin = nextFilters.keyword?.trim() || (hasKeywordInput ? '' : analysisVin);
|
||
const nextProtocol = nextFilters.protocol?.trim() ?? activeProtocol;
|
||
setAnalysisVin(nextVin);
|
||
setActiveProtocol(nextProtocol);
|
||
setHistoryTab('raw');
|
||
setHistoryFilters({ ...nextFilters, keyword: nextVin, protocol: nextProtocol });
|
||
setActivePage('history-query');
|
||
const { keyword, protocol, ...restFilters } = nextFilters;
|
||
replaceHash('history-query', keyword ?? nextVin, protocol ?? nextProtocol, { ...restFilters, 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 openMileageWithFilters = (filters: Record<string, string> = {}) => {
|
||
const nextFilters = normalizeMileageFilterValues(filters);
|
||
setCustomerTimeWindow(timeWindowFiltersFromValues(nextFilters));
|
||
const nextVin = nextFilters.keyword?.trim() || analysisVin;
|
||
const nextProtocol = nextFilters.protocol?.trim() ?? activeProtocol;
|
||
setAnalysisVin(nextVin);
|
||
setActiveProtocol(nextProtocol);
|
||
setMileageFilters({ ...nextFilters, keyword: nextVin, protocol: nextProtocol });
|
||
setActivePage('mileage');
|
||
const { keyword, protocol, ...restFilters } = nextFilters;
|
||
replaceHash('mileage', keyword ?? nextVin, protocol ?? nextProtocol, restFilters);
|
||
};
|
||
|
||
const currentCustomerTaskTimeWindow = () => {
|
||
const globalDateFrom = customerTimeWindow.dateFrom?.trim();
|
||
const globalDateTo = customerTimeWindow.dateTo?.trim();
|
||
return {
|
||
...(globalDateFrom ? { dateFrom: globalDateFrom } : {}),
|
||
...(globalDateTo ? { dateTo: globalDateTo } : {})
|
||
};
|
||
};
|
||
|
||
const openCustomerTask = (page: PageKey) => {
|
||
const keyword = (activeVin || analysisVin).trim();
|
||
const protocol = activeProtocol.trim();
|
||
const timeWindow = currentCustomerTaskTimeWindow();
|
||
if (!keyword) {
|
||
navigatePage(page);
|
||
return;
|
||
}
|
||
const scopedFilters = { keyword, protocol, ...timeWindow };
|
||
if (page === 'map') {
|
||
openMap({ keyword, protocol });
|
||
return;
|
||
}
|
||
if (page === 'realtime') {
|
||
openRealtime({ keyword, protocol });
|
||
return;
|
||
}
|
||
if (page === 'history') {
|
||
openHistoryWithFilters(scopedFilters);
|
||
return;
|
||
}
|
||
if (page === 'history-query') {
|
||
openRawWithFilters({ ...scopedFilters, tab: 'raw' });
|
||
return;
|
||
}
|
||
if (page === 'mileage') {
|
||
openMileageWithFilters(scopedFilters);
|
||
return;
|
||
}
|
||
if (page === 'time-monitor') {
|
||
setActivePage('time-monitor');
|
||
replaceHash('time-monitor', keyword, protocol, timeWindow);
|
||
return;
|
||
}
|
||
if (page === 'alert-events' || page === 'quality') {
|
||
openQuality(scopedFilters);
|
||
return;
|
||
}
|
||
navigatePage(page);
|
||
};
|
||
|
||
const customerScopeURL = (page: PageKey, filters: Record<string, string> = {}) => {
|
||
return `${window.location.origin}${window.location.pathname}${buildAppHash({
|
||
page,
|
||
keyword: filters.keyword,
|
||
protocol: filters.protocol,
|
||
filters
|
||
})}`;
|
||
};
|
||
|
||
const copyCustomerScope = async () => {
|
||
const keyword = (activeVin || analysisVin).trim();
|
||
if (!keyword) {
|
||
Toast.warning('请先查询一辆车');
|
||
return;
|
||
}
|
||
const protocol = activeProtocol.trim();
|
||
const timeWindow = currentCustomerTaskTimeWindow();
|
||
const scopedFilters = { keyword, protocol, ...timeWindow };
|
||
const rangeText = timeWindow.dateFrom || timeWindow.dateTo
|
||
? `${timeWindow.dateFrom || '未指定'} 至 ${timeWindow.dateTo || '未指定'}`
|
||
: '实时范围';
|
||
const lines = [
|
||
'【客户车辆服务范围】',
|
||
`车辆:${currentVehicleLabel || keyword}`,
|
||
`协议:${protocol || '全部'}`,
|
||
`时间窗:${rangeText}`,
|
||
`实时地图:${customerScopeURL('map', { keyword, protocol })}`,
|
||
`实时监控:${customerScopeURL('realtime', { keyword, protocol })}`,
|
||
`轨迹回放:${customerScopeURL('history', scopedFilters)}`,
|
||
`里程统计:${customerScopeURL('mileage', scopedFilters)}`,
|
||
`数据导出:${customerScopeURL('history-query', { ...scopedFilters, tab: 'raw' })}`,
|
||
`告警通知:${customerScopeURL('alert-events', scopedFilters)}`
|
||
];
|
||
try {
|
||
await navigator.clipboard.writeText(lines.join('\n'));
|
||
Toast.success('已复制客户服务范围');
|
||
} catch {
|
||
Toast.error('复制客户服务范围失败');
|
||
}
|
||
};
|
||
|
||
const saveCustomerView = () => {
|
||
const keyword = (activeVin || analysisVin).trim();
|
||
if (!keyword) {
|
||
Toast.warning('请先查询一辆车');
|
||
return;
|
||
}
|
||
const protocol = activeProtocol.trim();
|
||
const timeWindow = currentCustomerTaskTimeWindow();
|
||
const view: CustomerView = {
|
||
id: [keyword, protocol, timeWindow.dateFrom ?? '', timeWindow.dateTo ?? ''].join('|'),
|
||
label: currentVehicleLabel || keyword,
|
||
keyword,
|
||
protocol,
|
||
...timeWindow
|
||
};
|
||
setCustomerViews((prev) => {
|
||
const next = [view, ...prev.filter((item) => item.id !== view.id)].slice(0, 5);
|
||
writeCustomerViews(next);
|
||
return next;
|
||
});
|
||
Toast.success('已保存客户视图');
|
||
};
|
||
|
||
const openCustomerView = (view: CustomerView) => {
|
||
const keyword = view.keyword.trim();
|
||
if (!keyword) {
|
||
return;
|
||
}
|
||
const protocol = view.protocol?.trim() ?? '';
|
||
const timeWindow = {
|
||
...(view.dateFrom ? { dateFrom: view.dateFrom } : {}),
|
||
...(view.dateTo ? { dateTo: view.dateTo } : {})
|
||
};
|
||
setActiveVin(keyword);
|
||
setAnalysisVin(keyword);
|
||
setActiveProtocol(protocol);
|
||
setCurrentVehicleLabel(view.label || keyword);
|
||
setCustomerTimeWindow(timeWindow);
|
||
setActivePage('detail');
|
||
replaceHash('detail', keyword, protocol);
|
||
};
|
||
|
||
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} onOpenMap={openMap} onOpenRealtime={openRealtime} onOpenVehicles={openVehicles} onOpenHistory={openHistoryWithFilters} onOpenMileage={openMileageWithFilters} customerTimeWindow={customerTimeWindow} onCopyCustomerScope={copyCustomerScope} onSaveCustomerView={saveCustomerView} initialTimeMonitorFilters={{
|
||
...(activeVin || analysisVin ? { keyword: activeVin || analysisVin } : {}),
|
||
...(activeProtocol ? { protocol: activeProtocol } : {}),
|
||
...customerTimeWindow
|
||
}} />,
|
||
'time-monitor': <Dashboard focusMode="time-window" onOpenVehicle={openVehicle} onOpenQuality={openQuality} onOpenMap={openMap} onOpenRealtime={openRealtime} onOpenVehicles={openVehicles} onOpenHistory={openHistoryWithFilters} onOpenMileage={openMileageWithFilters} customerTimeWindow={customerTimeWindow} onCopyCustomerScope={copyCustomerScope} onSaveCustomerView={saveCustomerView} initialTimeMonitorFilters={{
|
||
...(activeVin || analysisVin ? { keyword: activeVin || analysisVin } : {}),
|
||
...(activeProtocol ? { protocol: activeProtocol } : {}),
|
||
...customerTimeWindow
|
||
}} />,
|
||
vehicles: <Vehicles onOpenVehicle={openVehicle} onOpenQuality={openQuality} onOpenMap={openMap} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenMileage={openMileageWithFilters} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
||
map: <Realtime mode="map" title="车辆实时地图" description="以车辆为中心查看位置分布、在线状态、关注车辆和轨迹入口" onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onOpenQuality={openQuality} onFiltersChange={updateMapFilters} initialFilters={realtimeFilters} />,
|
||
realtime: <Realtime onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onOpenQuality={openQuality} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
|
||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} platformRelease={platformRelease} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onOpenQuality={openQuality} onOpenHistoryEvidence={openHistoryWithFilters} onOpenRawEvidence={openRawWithFilters} onOpenMileageEvidence={openMileageWithFilters} onQueryChange={updateVehicleDetailQuery} />,
|
||
history: <History mode="trajectory" initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
|
||
'history-query': <History mode="query" initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
|
||
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} initialFilters={mileageFilters} onFiltersChange={updateMileageFilters} onOpenVehicle={openVehicle} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} />,
|
||
'alert-events': <Quality onOpenVehicle={openVehicle} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} onOpenMileage={openMileageWithFilters} onOpenNotificationRules={() => navigatePage('notification-rules')} onHealthLoaded={(health) => {
|
||
setLinkIssueCount((health.linkHealth ?? []).filter((item) => item.status !== 'ok').length);
|
||
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
||
}} onNotificationPlanLoaded={applyNotificationPlanSummary} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />,
|
||
quality: <Quality onOpenVehicle={openVehicle} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} onOpenMileage={openMileageWithFilters} onOpenNotificationRules={() => navigatePage('notification-rules')} onHealthLoaded={(health) => {
|
||
setLinkIssueCount((health.linkHealth ?? []).filter((item) => item.status !== 'ok').length);
|
||
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
||
}} onNotificationPlanLoaded={applyNotificationPlanSummary} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />,
|
||
'notification-rules': <NotificationRules />,
|
||
'ops-quality': <OpsQuality />
|
||
};
|
||
|
||
return (
|
||
<AppShell activePage={activePage} linkIssueCount={linkIssueCount} activeAlertRuleCount={activeAlertRuleCount} p0AlertRuleCount={p0AlertRuleCount} platformRelease={platformRelease} currentVehicleStatus={currentVehicleStatus} currentVehicleLabel={currentVehicleLabel} currentVehicleConsistency={currentVehicleConsistency} customerTimeWindow={customerTimeWindow} customerViews={customerViews} onCustomerTimeWindowChange={setCustomerTimeWindow} onSaveCustomerView={saveCustomerView} onOpenCustomerView={openCustomerView} onCopyCustomerScope={copyCustomerScope} onChange={navigatePage} onCustomerTask={openCustomerTask} onVehicleSearch={openVehicle}>
|
||
{pages[activePage]}
|
||
</AppShell>
|
||
);
|
||
}
|
||
|
||
function readCustomerViews(): CustomerView[] {
|
||
try {
|
||
const parsed = JSON.parse(window.localStorage.getItem(customerViewsStorageKey) || '[]');
|
||
return Array.isArray(parsed)
|
||
? parsed.filter((item): item is CustomerView => Boolean(item?.id && item?.label && item?.keyword)).slice(0, 5)
|
||
: [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function writeCustomerViews(views: CustomerView[]) {
|
||
window.localStorage.setItem(customerViewsStorageKey, JSON.stringify(views.slice(0, 5)));
|
||
}
|
||
|
||
function timeWindowFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
|
||
return timeWindowFiltersFromValues(route.filters ?? {});
|
||
}
|
||
|
||
function timeWindowFiltersFromValues(filters: Record<string, unknown>): Record<string, string> {
|
||
const dateFrom = String(filters.dateFrom ?? '').trim();
|
||
const dateTo = String(filters.dateTo ?? '').trim();
|
||
return {
|
||
...(dateFrom ? { dateFrom } : {}),
|
||
...(dateTo ? { dateTo } : {})
|
||
};
|
||
}
|
||
|
||
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 } : {}),
|
||
...(route.filters?.dateFrom ? { dateFrom: route.filters.dateFrom } : {}),
|
||
...(route.filters?.dateTo ? { dateTo: route.filters.dateTo } : {})
|
||
};
|
||
}
|
||
|
||
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', 'tab'] 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
|
||
};
|
||
}
|