From 3f7619c7cd1e6963c89a65ed701e049504f77167 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 05:41:05 +0800 Subject: [PATCH] perf(monitor): defer QR generator --- .../apps/web/scripts/verify-build.mjs | 21 ++++++++++++- .../web/src/v2/pages/MonitorPage.test.tsx | 31 +++++++++++++++++++ .../apps/web/src/v2/pages/MonitorPage.tsx | 14 ++++++--- .../apps/web/src/v2/styles/v2.css | 1 + .../docs/frontend-production-readiness.md | 8 +++++ 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/vehicle-data-platform/apps/web/scripts/verify-build.mjs b/vehicle-data-platform/apps/web/scripts/verify-build.mjs index 845816c3..da7eb9ad 100644 --- a/vehicle-data-platform/apps/web/scripts/verify-build.mjs +++ b/vehicle-data-platform/apps/web/scripts/verify-build.mjs @@ -5,6 +5,7 @@ const assetsDirectory = resolve(process.cwd(), 'dist/assets'); const assets = readdirSync(assetsDirectory); const excelAssets = assets.filter((name) => /^exceljs(?:\.min)?-[\w-]+\.js$/.test(name)); const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.js$/.test(name)); +const monitorAssets = assets.filter((name) => /^MonitorPage-[\w-]+\.js$/.test(name)); const runtimeConfig = readFileSync(resolve(process.cwd(), 'dist/app-config.js'), 'utf8'); const safeRuntimeTemplate = readFileSync(resolve(process.cwd(), 'public/app-config.example.js'), 'utf8'); const releaseManifest = readFileSync(resolve(process.cwd(), 'dist/.release-assets'), 'utf8').split(/\r?\n/).filter(Boolean); @@ -15,6 +16,9 @@ if (excelAssets.length !== 1) { if (mileageWorkers.length !== 1) { throw new Error(`production build must contain exactly one mileage export worker, found ${mileageWorkers.length}: ${mileageWorkers.join(', ') || 'none'}`); } +if (monitorAssets.length !== 1) { + throw new Error(`production build must contain exactly one monitor page asset, found ${monitorAssets.length}: ${monitorAssets.join(', ') || 'none'}`); +} if (runtimeConfig !== safeRuntimeTemplate) { throw new Error('production dist/app-config.js must match the credential-free runtime template'); } @@ -31,4 +35,19 @@ if (configuredSecurityCode) { const excelBytes = statSync(resolve(assetsDirectory, excelAssets[0])).size; const workerBytes = statSync(resolve(assetsDirectory, mileageWorkers[0])).size; -process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} runtime_config_sanitized=1\n`); +const monitorSource = readFileSync(resolve(assetsDirectory, monitorAssets[0]), 'utf8'); +const dynamicMonitorAssets = [...monitorSource.matchAll(/import\("\.\/([^"/]+\.js)"\)/g)].map((match) => match[1]); +const qrAssets = dynamicMonitorAssets.filter((name) => { + if (!assets.includes(name)) return false; + const source = readFileSync(resolve(assetsDirectory, name), 'utf8'); + return source.includes('No input text') && source.includes('toDataURL'); +}); +if (qrAssets.length !== 1) { + throw new Error(`monitor QR generator must be one deferred dynamic asset, found ${qrAssets.length}: ${qrAssets.join(', ') || 'none'}`); +} +const monitorBytes = statSync(resolve(assetsDirectory, monitorAssets[0])).size; +if (monitorBytes > 35_000) { + throw new Error(`monitor critical-path asset exceeds 35000 bytes: ${monitorBytes}`); +} +const qrBytes = statSync(resolve(assetsDirectory, qrAssets[0])).size; +process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} monitor_bytes=${monitorBytes} qr_deferred=1 qr_bytes=${qrBytes} runtime_config_sanitized=1\n`); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx index 674c3b6b..e1f2ddbc 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx @@ -20,6 +20,7 @@ const monitorMap = { clusters: [], points: [], total: 2 }; const fleetMapRenderSpy = vi.hoisted(() => vi.fn()); const vehicleCardArgsSpy = vi.hoisted(() => vi.fn()); const monitorDataArgsSpy = vi.hoisted(() => vi.fn()); +const qrToDataURLSpy = vi.hoisted(() => vi.fn()); const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false })); vi.mock('../map/FleetMap', () => ({ @@ -31,6 +32,8 @@ vi.mock('../map/FleetMap', () => ({ } })); +vi.mock('qrcode', () => ({ default: { toDataURL: qrToDataURLSpy } })); + vi.mock('../hooks/useMonitorData', () => ({ MAX_MONITOR_SEARCH_TERMS: 100, MONITOR_REFRESH: { selected: 10_000, fleet: 15_000, summary: 30_000 }, @@ -73,6 +76,7 @@ afterEach(() => { monitorQueryFlags.isPlaceholderData = false; vehicleCardArgsSpy.mockClear(); monitorDataArgsSpy.mockClear(); + qrToDataURLSpy.mockReset(); vi.restoreAllMocks(); }); @@ -166,6 +170,33 @@ test('pauses selected-vehicle polling in list mode and resumes it when returning expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), 'LTEST000000000001', true, true); }); +test('loads the QR generator only after opening the mobile entry', async () => { + qrToDataURLSpy.mockResolvedValue('data:image/png;base64,vehicle-monitor'); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + expect(qrToDataURLSpy).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: /手机端/ })); + + expect(await screen.findByRole('img', { name: '全局监控手机端二维码' })).toHaveAttribute('src', 'data:image/png;base64,vehicle-monitor'); + expect(qrToDataURLSpy).toHaveBeenCalledTimes(1); + expect(qrToDataURLSpy.mock.calls[0][0]).toMatch(/\/monitor$/); + expect(qrToDataURLSpy.mock.calls[0][0]).not.toContain('token'); +}); + +test('shows a retry action when on-demand QR generation fails', async () => { + qrToDataURLSpy.mockRejectedValueOnce(new Error('canvas unavailable')).mockResolvedValue('data:image/png;base64,recovered'); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + fireEvent.click(screen.getByRole('button', { name: /手机端/ })); + expect(await screen.findByRole('alert')).toHaveTextContent('二维码生成失败'); + fireEvent.click(screen.getByRole('button', { name: '重新生成' })); + + expect(await screen.findByRole('img', { name: '全局监控手机端二维码' })).toHaveAttribute('src', 'data:image/png;base64,recovered'); + expect(qrToDataURLSpy).toHaveBeenCalledTimes(2); +}); + test('mounts only the mobile list representation and removes its viewport listener', async () => { const addEventListener = vi.fn(); const removeEventListener = vi.fn(); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx index d2941780..68e11d24 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -1,6 +1,5 @@ import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin, IconQrCode, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; import { useQuery } from '@tanstack/react-query'; -import QRCode from 'qrcode'; import { memo, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../../api/client'; @@ -106,13 +105,20 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, on function MobileEntry({ onClose }: { onClose: () => void }) { const [qr, setQr] = useState(''); + const [error, setError] = useState(''); + const [attempt, setAttempt] = useState(0); const url = `${window.location.origin}/monitor`; useEffect(() => { let cancelled = false; - void QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#122033', light: '#ffffff' } }).then((value) => { if (!cancelled) setQr(value); }); + setQr(''); + setError(''); + void import('qrcode') + .then(({ default: QRCode }) => QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#122033', light: '#ffffff' } })) + .then((value) => { if (!cancelled) setQr(value); }) + .catch(() => { if (!cancelled) setError('二维码生成失败,请检查浏览器环境后重试'); }); return () => { cancelled = true; }; - }, [url]); - return

手机端全局监控

扫码后使用现有账号鉴权,不在二维码中保存 Token

{qr ? 全局监控手机端二维码 : }{url}
; + }, [attempt, url]); + return

手机端全局监控

扫码后使用现有账号鉴权,不在二维码中保存 Token

{qr ? 全局监控手机端二维码 : error ?
{error}
: }{url}
; } const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) { diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index aea660fe..75e128c9 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -165,6 +165,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-monitor-qr-backdrop { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; background: rgba(15,23,42,.42); padding: 18px; backdrop-filter: blur(4px); } .v2-monitor-qr-backdrop > section { position: relative; display: flex; width: min(360px, 100%); align-items: center; flex-direction: column; border-radius: 16px; background: #fff; padding: 26px; box-shadow: 0 24px 70px rgba(15,23,42,.26); text-align: center; } .v2-monitor-qr-backdrop > section > button:first-child { position: absolute; top: 12px; right: 12px; border: 0; background: transparent; color: #6b7a8f; cursor: pointer; }.v2-monitor-qr-backdrop > section > svg { color: var(--v2-blue); font-size: 26px; }.v2-monitor-qr-backdrop h3 { margin: 9px 0 4px; font-size: 17px; }.v2-monitor-qr-backdrop p { margin: 0 0 13px; color: #718096; font-size: 11px; }.v2-monitor-qr-backdrop img { width: 220px; height: 220px; }.v2-monitor-qr-backdrop code { width: 100%; overflow: hidden; margin-top: 10px; border-radius: 6px; background: #f5f7fa; padding: 8px; color: #5f6e82; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }.v2-monitor-qr-backdrop > section > button:last-child { width: 100%; height: 38px; margin-top: 10px; border: 0; border-radius: 7px; background: var(--v2-blue); color: #fff; font-weight: 700; cursor: pointer; } +.v2-monitor-qr-error { display: flex; width: 220px; height: 220px; align-items: center; justify-content: center; flex-direction: column; gap: 12px; border: 1px dashed #f0b7b7; border-radius: 10px; background: #fff7f7; padding: 18px; color: #a53a3a; font-size: 11px; line-height: 1.6; }.v2-monitor-qr-error button { height: 30px; border: 1px solid #e7a7a7; border-radius: 6px; background: #fff; padding: 0 12px; color: #a53a3a; cursor: pointer; font-size: 10px; font-weight: 700; } .v2-fleet-map { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #e8f0f6; } .v2-fleet-map-canvas { position: absolute; inset: 0; } diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index 138540cc..e6b16032 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -2,6 +2,14 @@ This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready. +## 2026-07-16: interaction-scoped QR dependency + +The monitor route statically imported the QR encoder even though it is used only after an operator opens the optional mobile-entry dialog. Every monitoring session therefore downloaded, parsed and retained the encoder. The production `MonitorPage` chunk was 50.26 kB raw / 17.97 kB gzip, and QR generation failures also left the dialog on an endless spinner because the Promise had no rejection state. + +The dialog now dynamically imports the encoder only after it mounts, cancels state publication when it closes, and renders a retryable error state when module loading or canvas generation fails. The monitor critical-path chunk fell to 25.50 kB raw / 8.30 kB gzip: 49.3% fewer raw bytes and 53.8% fewer compressed bytes. The isolated QR chunk is 25.78 kB raw / 10.13 kB gzip and is requested only by the interaction. This follows the platform dynamic-import model for conditionally loading low-probability or memory-heavy functionality: , . + +The production build gate now requires exactly one QR payload reachable through a dynamic import from the monitor chunk and caps that critical-path chunk at 35 kB, preventing an accidental static reintroduction. Component tests prove the encoder is untouched before the dialog opens, prove a successful QR contains only the monitor URL, and prove a failed generation can recover through the visible retry action. + ## 2026-07-16: visible-scope selected-vehicle polling Selecting a vehicle starts a ten-second realtime query so the map can keep its ripple, plate and camera target synchronized. Switching to the full fleet list unmounted the map and detail card but retained the selected VIN, so that invisible query continued polling up to 360 times per hour per open operator tab without any rendered consumer.