From 8e5cb5b8c7b91ba4effc295132e80f647ecd3f31 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 01:13:07 +0800 Subject: [PATCH] feat(platform-web): support shareable vehicle routes --- vehicle-data-platform/apps/web/src/App.tsx | 56 +++++++++++++++++-- .../apps/web/src/domain/appRoute.test.ts | 25 +++++++++ .../apps/web/src/domain/appRoute.ts | 33 +++++++++++ .../apps/web/src/test/App.test.tsx | 46 ++++++++++++++- 4 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/domain/appRoute.test.ts create mode 100644 vehicle-data-platform/apps/web/src/domain/appRoute.ts diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index 372c5ed0..4478e07a 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Toast } from '@douyinfe/semi-ui'; import { api } from './api/client'; +import { buildAppHash, parseAppHash } from './domain/appRoute'; import { AppShell, type PageKey } from './layout/AppShell'; import { Dashboard } from './pages/Dashboard'; import { History } from './pages/History'; @@ -11,9 +12,11 @@ import { VehicleDetail } from './pages/VehicleDetail'; import { Vehicles } from './pages/Vehicles'; export default function App() { - const [activePage, setActivePage] = useState('dashboard'); - const [activeVin, setActiveVin] = useState('LB9A32A24R0LS1426'); - const [analysisVin, setAnalysisVin] = useState('LB9A32A24R0LS1426'); + const initialRoute = parseAppHash(window.location.hash); + const initialVehicleKey = initialRoute.keyword || 'LB9A32A24R0LS1426'; + const [activePage, setActivePage] = useState(initialRoute.page ?? 'dashboard'); + const [activeVin, setActiveVin] = useState(initialVehicleKey); + const [analysisVin, setAnalysisVin] = useState(initialVehicleKey); const [linkIssueCount, setLinkIssueCount] = useState(null); const refreshOpsHealth = useCallback((showError = true) => { @@ -37,6 +40,46 @@ export default function App() { 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); + } + } + }; + 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 lookupKey = keyword.trim(); if (!lookupKey) { @@ -47,6 +90,7 @@ export default function App() { const nextKey = resolved.resolved && resolved.vin ? resolved.vin : lookupKey; setActiveVin(nextKey); setActivePage('detail'); + replaceHash('detail', nextKey); if (!resolved.resolved) { Toast.warning('未匹配到车辆身份,已打开问题排查视图'); } @@ -62,6 +106,7 @@ export default function App() { } setAnalysisVin(nextVin); setActivePage('history'); + replaceHash('history', nextVin); }; const openMileageForVehicle = (vin: string) => { @@ -71,10 +116,11 @@ export default function App() { } setAnalysisVin(nextVin); setActivePage('mileage'); + replaceHash('mileage', nextVin); }; const pages: Record = { - dashboard: setActivePage('quality')} />, + dashboard: navigatePage('quality')} />, vehicles: , realtime: , detail: , @@ -84,7 +130,7 @@ export default function App() { }; return ( - + {pages[activePage]} ); diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts new file mode 100644 index 00000000..967cc7bf --- /dev/null +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts @@ -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'); + }); +}); diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.ts new file mode 100644 index 00000000..a7bb4bf6 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.ts @@ -0,0 +1,33 @@ +import type { PageKey } from '../layout/AppShell'; + +const pageKeys = new Set(['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}`; +} diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index f94e1688..1b5a7d5a 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -1,9 +1,53 @@ import { render, screen } from '@testing-library/react'; -import { expect, test } from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; import App from '../App'; +afterEach(() => { + window.history.replaceState(null, '', '/'); + vi.restoreAllMocks(); +}); + test('renders vehicle platform shell', () => { render(); expect(screen.getByText('车辆服务中台')).toBeInTheDocument(); 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(); + + expect(await screen.findByDisplayValue('粤AG18312')).toBeInTheDocument(); + expect(screen.getAllByText('车辆服务').length).toBeGreaterThanOrEqual(1); +});