perf(monitor): defer QR generator
This commit is contained in:
@@ -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`);
|
||||
|
||||
@@ -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(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
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(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
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();
|
||||
|
||||
@@ -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 <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口"><section><button type="button" onClick={onClose} aria-label="关闭手机端入口"><IconClose /></button><IconQrCode /><h3>手机端全局监控</h3><p>扫码后使用现有账号鉴权,不在二维码中保存 Token</p>{qr ? <img src={qr} alt="全局监控手机端二维码" /> : <span className="v2-spinner" />}<code>{url}</code><button type="button" onClick={() => void navigator.clipboard?.writeText(url)}>复制访问地址</button></section></div>;
|
||||
}, [attempt, url]);
|
||||
return <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口"><section><button type="button" onClick={onClose} aria-label="关闭手机端入口"><IconClose /></button><IconQrCode /><h3>手机端全局监控</h3><p>扫码后使用现有账号鉴权,不在二维码中保存 Token</p>{qr ? <img src={qr} alt="全局监控手机端二维码" /> : error ? <div className="v2-monitor-qr-error" role="alert"><span>{error}</span><button type="button" onClick={() => setAttempt((value) => value + 1)}>重新生成</button></div> : <span className="v2-spinner" role="status" aria-label="正在生成二维码" />}<code>{url}</code><button type="button" onClick={() => void navigator.clipboard?.writeText(url)}>复制访问地址</button></section></div>;
|
||||
}
|
||||
|
||||
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user