diff --git a/docs/更新说明.md b/docs/更新说明.md index a8d58ea..a8b587c 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,17 @@ --- +## 2026-07-27 — 手机端真·竖屏(禁横屏布局) + +### 变更 + +1. 去掉「请竖屏使用」遮罩:那只是提示,不是锁定。 +2. **能锁系统方向时**(常见:安卓安装到主屏幕的 PWA)调用 `orientation.lock`。 +3. **锁不住时**(多数浏览器页 / iOS):设备横放也不进入横屏 UI,界面仍按竖屏坐标系渲染。 +4. Manifest:`orientation` 改为 `portrait-primary`。 + +--- + ## 2026-07-27 — 手机端竖屏锁定 ### 变更 diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest index d25b8b6..c1cdad2 100644 --- a/frontend/public/manifest.webmanifest +++ b/frontend/public/manifest.webmanifest @@ -5,8 +5,8 @@ "start_url": "/plan", "scope": "/", "display": "standalone", - "display_override": ["standalone", "minimal-ui", "browser"], - "orientation": "portrait", + "display_override": ["standalone", "fullscreen", "minimal-ui"], + "orientation": "portrait-primary", "background_color": "#0b0e11", "theme_color": "#0b0e11", "lang": "zh-CN", diff --git a/frontend/src/pwa/portraitLock.ts b/frontend/src/pwa/portraitLock.ts index 61c0fe9..8eb963f 100644 --- a/frontend/src/pwa/portraitLock.ts +++ b/frontend/src/pwa/portraitLock.ts @@ -1,4 +1,8 @@ -/** 手机端尽量锁定竖屏;浏览器页用遮罩提示,已安装 PWA 由 manifest 约束。 */ +/** + * 手机端竖屏锁定: + * 1) 能锁系统方向时直接 lock(安卓 PWA / 部分全屏环境) + * 2) 锁不住时,横屏仍强制按竖屏布局渲染(不进入横屏 UI) + */ type OrientationLockType = | "any" @@ -12,64 +16,66 @@ type OrientationLockType = function isPhoneLike(): boolean { if (typeof window === "undefined") return false; - const coarse = window.matchMedia("(pointer: coarse)").matches; - const narrow = Math.min(window.screen.width, window.screen.height) <= 520; const ua = navigator.userAgent || ""; - const phoneUa = /Android.+Mobile|iPhone|iPod|Windows Phone/i.test(ua); - return phoneUa || (coarse && narrow); + if (/Android.+Mobile|iPhone|iPod|Windows Phone/i.test(ua)) return true; + const coarse = window.matchMedia("(pointer: coarse)").matches; + const shortSide = Math.min(window.screen.width, window.screen.height); + return coarse && shortSide <= 520; } -async function tryLockPortrait(): Promise { +function isLandscape(): boolean { + return window.matchMedia("(orientation: landscape)").matches; +} + +async function tryLockPortrait(): Promise { + if (!isPhoneLike()) return false; const orient = screen.orientation as ScreenOrientation & { lock?: (orientation: OrientationLockType) => Promise; }; - if (!orient?.lock) return; + if (!orient?.lock) return false; try { await orient.lock("portrait"); + return true; } catch { try { await orient.lock("portrait-primary"); + return true; } catch { - /* 多数浏览器仅全屏/PWA 允许 lock,失败则靠遮罩 */ + return false; } } } -function ensureGate(): HTMLElement { - let el = document.getElementById("portrait-lock-gate"); - if (el) return el; - el = document.createElement("div"); - el.id = "portrait-lock-gate"; - el.className = "portrait-lock-gate"; - el.setAttribute("aria-live", "polite"); - el.innerHTML = - '
' + - '

请竖屏使用

' + - '

手机端仅支持竖屏浏览

' + - "
"; - document.body.appendChild(el); - return el; -} - -function syncGate(): void { +function syncForcedPortrait(): void { + const html = document.documentElement; if (!isPhoneLike()) { - const el = document.getElementById("portrait-lock-gate"); - if (el) el.classList.remove("show"); + html.classList.remove("phone-portrait-lock", "phone-landscape"); return; } - const landscape = window.matchMedia("(orientation: landscape)").matches; - ensureGate().classList.toggle("show", landscape); - if (!landscape) void tryLockPortrait(); + html.classList.add("phone-portrait-lock"); + html.classList.toggle("phone-landscape", isLandscape()); } -/** 注册竖屏锁定与横屏提示遮罩。 */ +/** 手机端:系统竖屏锁 + 横屏时强制竖屏排版。 */ export function initPortraitLock(): void { if (typeof window === "undefined") return; - syncGate(); - window.addEventListener("orientationchange", syncGate); - window.addEventListener("resize", syncGate); + + const sync = () => { + syncForcedPortrait(); + void tryLockPortrait(); + }; + + sync(); + window.addEventListener("orientationchange", sync); + window.addEventListener("resize", sync); document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") syncGate(); + if (document.visibilityState === "visible") sync(); }); - void tryLockPortrait(); + + // 部分浏览器要求用户手势后才能 orientation.lock + const onGesture = () => { + void tryLockPortrait().then(() => syncForcedPortrait()); + }; + window.addEventListener("pointerdown", onGesture, { passive: true }); + window.addEventListener("touchstart", onGesture, { passive: true }); } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 584b36d..345bb57 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -33,37 +33,37 @@ body { var(--bg); } -.portrait-lock-gate { - display: none; +/* + * 手机横屏时不进入横屏布局:把 #root 旋成竖屏坐标系。 + * 能调用 screen.orientation.lock 的环境(如安卓已安装 PWA)则系统本身不转,此类样式不生效。 + */ +html.phone-portrait-lock.phone-landscape, +html.phone-portrait-lock.phone-landscape body { + width: 100%; + height: 100%; + overflow: hidden; + overscroll-behavior: none; +} + +html.phone-portrait-lock.phone-landscape body { position: fixed; inset: 0; - z-index: 9999; - align-items: center; - justify-content: center; - padding: 24px; - background: rgba(11, 14, 17, 0.96); - text-align: center; } -.portrait-lock-gate.show { - display: flex; -} - -.portrait-lock-card { - max-width: 280px; -} - -.portrait-lock-title { - margin: 0 0 8px; - font-size: 20px; - font-weight: 700; - color: var(--accent); -} - -.portrait-lock-sub { - margin: 0; - font-size: 14px; - color: var(--muted); +html.phone-portrait-lock.phone-landscape #root { + position: absolute; + width: 100vh; + width: 100dvh; + height: 100vw; + height: 100dvw; + top: 100%; + top: 100dvh; + left: 0; + transform: rotate(-90deg); + transform-origin: left top; + overflow: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: none; } button,