diff --git a/frontend/src/main.ts b/frontend/src/main.ts index d7c17b466..8d29b4c66 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -5,8 +5,23 @@ import router from './router' import i18n, { initI18n } from './i18n' import { useAppStore } from '@/stores/app' import { updateFavicon } from '@/utils/branding' +import { isIOSDevice } from '@/utils/device' import './style.css' +function initIOSViewportZoomFix() { + // iOS Safari 在输入框字号小于 16px 时聚焦会自动放大页面,且失焦后不会恢复。 + // 限制 maximum-scale 可阻止该行为;iOS 10+ 用户仍可双指手动缩放,不影响可访问性。 + // 仅在 iOS 设备上注入,避免影响 Android Chrome 的手动缩放能力。 + if (!isIOSDevice()) return + + const viewport = document.querySelector('meta[name="viewport"]') + if (!viewport) return + + const content = viewport.getAttribute('content') || '' + if (/maximum-scale/i.test(content)) return + viewport.setAttribute('content', `${content}, maximum-scale=1.0`) +} + function initThemeClass() { const savedTheme = localStorage.getItem('theme') const shouldUseDark = @@ -18,6 +33,7 @@ function initThemeClass() { async function bootstrap() { // Apply theme class globally before app mount to keep all routes consistent. initThemeClass() + initIOSViewportZoomFix() const app = createApp(App) const pinia = createPinia() diff --git a/frontend/src/utils/__tests__/device.spec.ts b/frontend/src/utils/__tests__/device.spec.ts index fad14e754..cbb4477bc 100644 --- a/frontend/src/utils/__tests__/device.spec.ts +++ b/frontend/src/utils/__tests__/device.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { detectMobileDevice } from '../device' +import { detectIOSDevice, detectMobileDevice } from '../device' describe('detectMobileDevice', () => { it('prefers userAgentData.mobile when available', () => { @@ -53,3 +53,43 @@ describe('detectMobileDevice', () => { })).toBe(false) }) }) + +describe('detectIOSDevice', () => { + it('recognizes iPhone from the UA token', () => { + expect(detectIOSDevice({ + navigator: { + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1', + maxTouchPoints: 5, + }, + })).toBe(true) + }) + + it('recognizes iPadOS desktop mode via touch capability', () => { + expect(detectIOSDevice({ + navigator: { + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15', + platform: 'MacIntel', + maxTouchPoints: 5, + }, + })).toBe(true) + }) + + it('keeps Android devices as non-iOS', () => { + expect(detectIOSDevice({ + navigator: { + userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/136.0 Mobile Safari/537.36', + maxTouchPoints: 5, + }, + })).toBe(false) + }) + + it('keeps desktop macOS without touch as non-iOS', () => { + expect(detectIOSDevice({ + navigator: { + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/136.0 Safari/537.36', + platform: 'MacIntel', + maxTouchPoints: 0, + }, + })).toBe(false) + }) +}) diff --git a/frontend/src/utils/device.ts b/frontend/src/utils/device.ts index e60b10fd4..cd3930c5e 100644 --- a/frontend/src/utils/device.ts +++ b/frontend/src/utils/device.ts @@ -20,6 +20,7 @@ interface DeviceDetectionEnvironment { const MOBILE_UA_RE = /\b(Mobi|Android|iPhone|iPod|Windows Phone|webOS|BlackBerry|IEMobile)\b/i const TABLET_UA_RE = /\b(iPad|Tablet)\b/i +const IOS_UA_RE = /\b(iPhone|iPad|iPod)\b/i function matchesQuery( matchMedia: DeviceDetectionEnvironment['matchMedia'], @@ -60,3 +61,21 @@ export function isMobileDevice(): boolean { matchMedia: typeof window !== 'undefined' ? window.matchMedia.bind(window) : undefined, }) } + +export function detectIOSDevice(env: DeviceDetectionEnvironment = {}): boolean { + const nav = env.navigator + if (!nav) return false + + const userAgent = nav.userAgent || '' + const maxTouchPoints = nav.maxTouchPoints ?? 0 + // iPadOS 13+ 桌面模式下 UA 伪装成 macOS,需要靠触控点数识别。 + const isIPadOSDesktopMode = nav.platform === 'MacIntel' && maxTouchPoints > 1 + + return IOS_UA_RE.test(userAgent) || isIPadOSDesktopMode +} + +export function isIOSDevice(): boolean { + if (typeof navigator === 'undefined') return false + + return detectIOSDevice({ navigator }) +}