fix(monitor): harden mobile entry dialog

This commit is contained in:
lingniu
2026-07-16 08:20:15 +08:00
parent 473c9e1251
commit 4b2e743229
2 changed files with 41 additions and 1 deletions

View File

@@ -22,6 +22,7 @@ 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 }));
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
vi.mock('../map/FleetMap', () => ({
FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => {
@@ -77,6 +78,8 @@ afterEach(() => {
vehicleCardArgsSpy.mockClear();
monitorDataArgsSpy.mockClear();
qrToDataURLSpy.mockReset();
if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard);
else Reflect.deleteProperty(navigator, 'clipboard');
vi.restoreAllMocks();
});
@@ -186,6 +189,23 @@ test('loads the QR generator only after opening the mobile entry', async () => {
expect(qrToDataURLSpy.mock.calls[0][0]).not.toContain('token');
});
test('closes the mobile entry with Escape and reports clipboard success', async () => {
qrToDataURLSpy.mockResolvedValue('data:image/png;base64,vehicle-monitor');
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
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('img', { name: '全局监控手机端二维码' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '复制访问地址' }));
expect(await screen.findByRole('button', { name: '已复制访问地址' })).toBeInTheDocument();
expect(writeText).toHaveBeenCalledWith(expect.stringMatching(/\/monitor$/));
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '手机端入口' })).not.toBeInTheDocument();
});
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 } } });

View File

@@ -106,8 +106,19 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, on
function MobileEntry({ onClose }: { onClose: () => void }) {
const [qr, setQr] = useState('');
const [error, setError] = useState('');
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle');
const [attempt, setAttempt] = useState(0);
const url = `${window.location.origin}/monitor`;
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [onClose]);
useEffect(() => {
if (copyState === 'idle') return;
const timer = window.setTimeout(() => setCopyState('idle'), 1_800);
return () => window.clearTimeout(timer);
}, [copyState]);
useEffect(() => {
let cancelled = false;
setQr('');
@@ -118,7 +129,16 @@ function MobileEntry({ onClose }: { onClose: () => void }) {
.catch(() => { if (!cancelled) setError('二维码生成失败,请检查浏览器环境后重试'); });
return () => { cancelled = true; };
}, [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 copyURL = async () => {
try {
if (!navigator.clipboard?.writeText) throw new Error('clipboard unavailable');
await navigator.clipboard.writeText(url);
setCopyState('copied');
} catch {
setCopyState('error');
}
};
return <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><section><button type="button" autoFocus 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" aria-live="polite" onClick={() => void copyURL()}>{copyState === 'copied' ? '已复制访问地址' : copyState === 'error' ? '复制失败,重试' : '复制访问地址'}</button></section></div>;
}
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {