feat(platform-web): support shareable vehicle routes
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
25
vehicle-data-platform/apps/web/src/domain/appRoute.test.ts
Normal file
25
vehicle-data-platform/apps/web/src/domain/appRoute.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
33
vehicle-data-platform/apps/web/src/domain/appRoute.ts
Normal file
33
vehicle-data-platform/apps/web/src/domain/appRoute.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user