feat(platform-web): support shareable vehicle routes

This commit is contained in:
lingniu
2026-07-04 01:13:07 +08:00
parent beb5034b9b
commit 8e5cb5b8c7
4 changed files with 154 additions and 6 deletions

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Toast } from '@douyinfe/semi-ui'; import { Toast } from '@douyinfe/semi-ui';
import { api } from './api/client'; import { api } from './api/client';
import { buildAppHash, parseAppHash } from './domain/appRoute';
import { AppShell, type PageKey } from './layout/AppShell'; import { AppShell, type PageKey } from './layout/AppShell';
import { Dashboard } from './pages/Dashboard'; import { Dashboard } from './pages/Dashboard';
import { History } from './pages/History'; import { History } from './pages/History';
@@ -11,9 +12,11 @@ import { VehicleDetail } from './pages/VehicleDetail';
import { Vehicles } from './pages/Vehicles'; import { Vehicles } from './pages/Vehicles';
export default function App() { export default function App() {
const [activePage, setActivePage] = useState<PageKey>('dashboard'); const initialRoute = parseAppHash(window.location.hash);
const [activeVin, setActiveVin] = useState('LB9A32A24R0LS1426'); const initialVehicleKey = initialRoute.keyword || 'LB9A32A24R0LS1426';
const [analysisVin, setAnalysisVin] = useState('LB9A32A24R0LS1426'); const [activePage, setActivePage] = useState<PageKey>(initialRoute.page ?? 'dashboard');
const [activeVin, setActiveVin] = useState(initialVehicleKey);
const [analysisVin, setAnalysisVin] = useState(initialVehicleKey);
const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null); const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
const refreshOpsHealth = useCallback((showError = true) => { const refreshOpsHealth = useCallback((showError = true) => {
@@ -37,6 +40,46 @@ export default function App() {
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [refreshOpsHealth]); }, [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);
}
}
};
window.addEventListener('hashchange', applyHashRoute);
return () => window.removeEventListener('hashchange', applyHashRoute);
}, []);
const replaceHash = (page: PageKey, keyword?: string) => {
const nextHash = buildAppHash({ page, keyword });
if (window.location.hash !== nextHash) {
window.history.replaceState(null, '', nextHash);
}
};
const navigatePage = (page: PageKey) => {
setActivePage(page);
if (page === 'detail') {
replaceHash(page, activeVin);
return;
}
if (page === 'history' || page === 'mileage') {
replaceHash(page, analysisVin);
return;
}
replaceHash(page);
};
const openVehicle = async (keyword: string) => { const openVehicle = async (keyword: string) => {
const lookupKey = keyword.trim(); const lookupKey = keyword.trim();
if (!lookupKey) { if (!lookupKey) {
@@ -47,6 +90,7 @@ export default function App() {
const nextKey = resolved.resolved && resolved.vin ? resolved.vin : lookupKey; const nextKey = resolved.resolved && resolved.vin ? resolved.vin : lookupKey;
setActiveVin(nextKey); setActiveVin(nextKey);
setActivePage('detail'); setActivePage('detail');
replaceHash('detail', nextKey);
if (!resolved.resolved) { if (!resolved.resolved) {
Toast.warning('未匹配到车辆身份,已打开问题排查视图'); Toast.warning('未匹配到车辆身份,已打开问题排查视图');
} }
@@ -62,6 +106,7 @@ export default function App() {
} }
setAnalysisVin(nextVin); setAnalysisVin(nextVin);
setActivePage('history'); setActivePage('history');
replaceHash('history', nextVin);
}; };
const openMileageForVehicle = (vin: string) => { const openMileageForVehicle = (vin: string) => {
@@ -71,10 +116,11 @@ export default function App() {
} }
setAnalysisVin(nextVin); setAnalysisVin(nextVin);
setActivePage('mileage'); setActivePage('mileage');
replaceHash('mileage', nextVin);
}; };
const pages: Record<PageKey, JSX.Element> = { const pages: Record<PageKey, JSX.Element> = {
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={() => setActivePage('quality')} />, dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={() => navigatePage('quality')} />,
vehicles: <Vehicles onOpenVehicle={openVehicle} />, vehicles: <Vehicles onOpenVehicle={openVehicle} />,
realtime: <Realtime onOpenVehicle={openVehicle} />, realtime: <Realtime onOpenVehicle={openVehicle} />,
detail: <VehicleDetail vin={activeVin} onOpenHistory={openHistoryForVehicle} onOpenMileage={openMileageForVehicle} />, detail: <VehicleDetail vin={activeVin} onOpenHistory={openHistoryForVehicle} onOpenMileage={openMileageForVehicle} />,
@@ -84,7 +130,7 @@ export default function App() {
}; };
return ( return (
<AppShell activePage={activePage} linkIssueCount={linkIssueCount} onChange={setActivePage} onVehicleSearch={openVehicle}> <AppShell activePage={activePage} linkIssueCount={linkIssueCount} onChange={navigatePage} onVehicleSearch={openVehicle}>
{pages[activePage]} {pages[activePage]}
</AppShell> </AppShell>
); );

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { buildAppHash, parseAppHash } from './appRoute';
describe('parseAppHash', () => {
test('parses detail page keyword from hash query', () => {
expect(parseAppHash('#/detail?keyword=%E7%B2%A4AG18312')).toEqual({
page: 'detail',
keyword: '粤AG18312'
});
});
test('ignores unknown pages', () => {
expect(parseAppHash('#/unknown?keyword=VIN001')).toEqual({});
});
});
describe('buildAppHash', () => {
test('builds shareable vehicle page hash with encoded keyword', () => {
expect(buildAppHash({ page: 'history', keyword: '粤AG18312' })).toBe('#/history?keyword=%E7%B2%A4AG18312');
});
test('builds page-only hash when keyword is empty', () => {
expect(buildAppHash({ page: 'quality', keyword: '' })).toBe('#/quality');
});
});

View File

@@ -0,0 +1,33 @@
import type { PageKey } from '../layout/AppShell';
const pageKeys = new Set<PageKey>(['dashboard', 'vehicles', 'realtime', 'detail', 'history', 'mileage', 'quality']);
export type AppRoute = {
page?: PageKey;
keyword?: string;
};
export function parseAppHash(hash: string): AppRoute {
const normalized = hash.trim().replace(/^#\/?/, '');
if (!normalized) {
return {};
}
const [pagePart, queryPart = ''] = normalized.split('?', 2);
if (!pageKeys.has(pagePart as PageKey)) {
return {};
}
const params = new URLSearchParams(queryPart);
const keyword = params.get('keyword')?.trim() || undefined;
return { page: pagePart as PageKey, keyword };
}
export function buildAppHash(route: AppRoute): string {
const page = route.page && pageKeys.has(route.page) ? route.page : 'dashboard';
const params = new URLSearchParams();
const keyword = route.keyword?.trim();
if (keyword) {
params.set('keyword', keyword);
}
const query = params.toString();
return query ? `#/${page}?${query}` : `#/${page}`;
}

View File

@@ -1,9 +1,53 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import App from '../App'; import App from '../App';
afterEach(() => {
window.history.replaceState(null, '', '/');
vi.restoreAllMocks();
});
test('renders vehicle platform shell', () => { test('renders vehicle platform shell', () => {
render(<App />); render(<App />);
expect(screen.getByText('车辆服务中台')).toBeInTheDocument(); expect(screen.getByText('车辆服务中台')).toBeInTheDocument();
expect(screen.getAllByText('总览工作台').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('总览工作台').length).toBeGreaterThanOrEqual(1);
}); });
test('opens vehicle detail from shareable hash', async () => {
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
data: {
vin: 'LB9A32A24R0LS1426',
lookupKey: '粤AG18312',
lookupResolved: true,
resolution: {
lookupKey: '粤AG18312',
resolved: true,
vin: 'LB9A32A24R0LS1426',
plate: '粤AG18312',
phone: '13307795425',
oem: 'G7s',
protocols: ['GB32960', 'JT808'],
online: true,
lastSeen: '2026-07-03 20:12:10'
},
sources: ['GB32960', 'JT808'],
sourceStatus: [],
realtime: [],
history: { items: [], total: 0, limit: 20, offset: 0 },
raw: { items: [], total: 0, limit: 10, offset: 0 },
mileage: { items: [], total: 0, limit: 20, offset: 0 },
quality: { items: [], total: 0, limit: 20, offset: 0 }
},
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response);
render(<App />);
expect(await screen.findByDisplayValue('粤AG18312')).toBeInTheDocument();
expect(screen.getAllByText('车辆服务').length).toBeGreaterThanOrEqual(1);
});