diff --git a/docs/更新说明.md b/docs/更新说明.md index a8b587c..b988ff4 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,17 @@ --- +## 2026-07-27 — 安卓竖屏:全屏后系统锁定 + +### 变更 + +1. 说明:安卓 **Chrome 普通标签页** 不允许网页直接锁方向,必须先全屏再 `orientation.lock`(你以前能锁的,多半是 App/WebView/已装桌面,或点过全屏)。 +2. 安卓首次点击 / 「点击锁定竖屏」:进入全屏并系统锁竖屏。 +3. 已「添加到主屏幕」的应用走 manifest `orientation: portrait`,一般无需全屏。 +4. 补充 X5/UC 竖屏 meta;锁失败时仍强制竖屏布局。 + +--- + ## 2026-07-27 — 手机端真·竖屏(禁横屏布局) ### 变更 diff --git a/frontend/index.html b/frontend/index.html index 04c1209..dd12cbd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ @@ -13,6 +13,13 @@ + + + + + + + window.innerHeight; } -async function tryLockPortrait(): Promise { - if (!isPhoneLike()) return false; +function tryLegacyLock(): boolean { + const s = screen as Screen & { + lockOrientation?: (o: string) => boolean; + mozLockOrientation?: (o: string) => boolean; + msLockOrientation?: (o: string) => boolean; + }; + try { + if (s.lockOrientation?.("portrait") || s.lockOrientation?.("portrait-primary")) { + return true; + } + if (s.mozLockOrientation?.("portrait")) return true; + if (s.msLockOrientation?.("portrait")) return true; + } catch { + /* ignore */ + } + return false; +} + +async function tryModernLock(): Promise { const orient = screen.orientation as ScreenOrientation & { lock?: (orientation: OrientationLockType) => Promise; }; if (!orient?.lock) return false; - try { - await orient.lock("portrait"); - return true; - } catch { + for (const mode of ["portrait", "portrait-primary"] as OrientationLockType[]) { try { - await orient.lock("portrait-primary"); + await orient.lock(mode); return true; } catch { - return false; + /* try next */ } } + return false; } -function syncForcedPortrait(): void { +async function ensureFullscreen(): Promise { + if (document.fullscreenElement) return true; + const el = document.documentElement as HTMLElement & { + webkitRequestFullscreen?: () => void; + }; + try { + if (typeof el.requestFullscreen === "function") { + await el.requestFullscreen( + { navigationUI: "hide" } as FullscreenOptions, + ); + return Boolean(document.fullscreenElement); + } + if (typeof el.webkitRequestFullscreen === "function") { + el.webkitRequestFullscreen(); + return true; + } + } catch { + return false; + } + return Boolean(document.fullscreenElement); +} + +/** 系统级竖屏锁。fromGesture=true 时安卓浏览器可先全屏再锁。 */ +export async function lockPortraitHard(fromGesture: boolean): Promise { + if (!isPhoneLike()) return false; + if (tryLegacyLock()) { + osLocked = true; + return true; + } + if (await tryModernLock()) { + osLocked = true; + return true; + } + // Chrome 标签页:全屏是 lock 的前置条件 + if (fromGesture && isAndroid() && !isStandaloneDisplay()) { + await ensureFullscreen(); + if (tryLegacyLock() || (await tryModernLock())) { + osLocked = true; + return true; + } + } + return false; +} + +function clearRootForce(root: HTMLElement): void { + root.style.position = ""; + root.style.width = ""; + root.style.height = ""; + root.style.left = ""; + root.style.top = ""; + root.style.transform = ""; + root.style.transformOrigin = ""; + root.style.overflow = ""; + root.style.zIndex = ""; +} + +/** 系统锁失败且当前横屏时,强制竖屏坐标系(程序不走横屏布局)。 */ +function syncForcedPortraitLayout(): void { const html = document.documentElement; + const root = document.getElementById("root"); + if (!root) return; + if (!isPhoneLike()) { html.classList.remove("phone-portrait-lock", "phone-landscape"); + clearRootForce(root); return; } + html.classList.add("phone-portrait-lock"); - html.classList.toggle("phone-landscape", isLandscape()); + + // 系统已锁且当前是竖屏:清强制样式 + if (osLocked && !isLandscape()) { + html.classList.remove("phone-landscape"); + clearRootForce(root); + return; + } + + if (!isLandscape()) { + html.classList.remove("phone-landscape"); + clearRootForce(root); + return; + } + + html.classList.add("phone-landscape"); + const w = window.innerHeight; + const h = window.innerWidth; + root.style.position = "fixed"; + root.style.width = `${w}px`; + root.style.height = `${h}px`; + root.style.left = "0px"; + root.style.top = `${w}px`; + root.style.transformOrigin = "left top"; + root.style.transform = "rotate(-90deg)"; + root.style.overflow = "auto"; + root.style.zIndex = "1"; + root.style.setProperty("-webkit-overflow-scrolling", "touch"); } -/** 手机端:系统竖屏锁 + 横屏时强制竖屏排版。 */ +function syncHint(): void { + let hint = document.getElementById("portrait-lock-hint"); + const need = + isPhoneLike() && + isAndroid() && + !isStandaloneDisplay() && + !osLocked && + !document.fullscreenElement; + + if (!need) { + hint?.remove(); + return; + } + if (!hint) { + hint = document.createElement("button"); + hint.id = "portrait-lock-hint"; + hint.type = "button"; + hint.className = "portrait-lock-hint"; + hint.textContent = "点击锁定竖屏"; + hint.addEventListener("click", (ev) => { + ev.preventDefault(); + void (async () => { + await lockPortraitHard(true); + syncForcedPortraitLayout(); + syncHint(); + })(); + }); + document.body.appendChild(hint); + } +} + +/** 启动竖屏锁定。安卓浏览器需点一下(全屏+锁);已装主屏幕应用通常自动锁。 */ export function initPortraitLock(): void { if (typeof window === "undefined") return; const sync = () => { - syncForcedPortrait(); - void tryLockPortrait(); + syncForcedPortraitLayout(); + syncHint(); }; + void lockPortraitHard(false).then(sync); sync(); - window.addEventListener("orientationchange", sync); + + window.addEventListener("orientationchange", () => { + window.setTimeout(sync, 50); + void lockPortraitHard(false).then(sync); + }); window.addEventListener("resize", sync); + document.addEventListener("fullscreenchange", () => { + if (!document.fullscreenElement) osLocked = false; + sync(); + }); document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") sync(); + if (document.visibilityState === "visible") { + void lockPortraitHard(false).then(sync); + } }); - // 部分浏览器要求用户手势后才能 orientation.lock const onGesture = () => { - void tryLockPortrait().then(() => syncForcedPortrait()); + void lockPortraitHard(true).then(sync); }; - window.addEventListener("pointerdown", onGesture, { passive: true }); - window.addEventListener("touchstart", onGesture, { passive: true }); + // 捕获阶段,保证第一次触摸就能触发全屏+锁 + window.addEventListener("pointerdown", onGesture, { + capture: true, + passive: true, + }); + window.addEventListener("touchstart", onGesture, { + capture: true, + passive: true, + }); } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 345bb57..b09d7c5 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -34,8 +34,8 @@ body { } /* - * 手机横屏时不进入横屏布局:把 #root 旋成竖屏坐标系。 - * 能调用 screen.orientation.lock 的环境(如安卓已安装 PWA)则系统本身不转,此类样式不生效。 + * 手机横屏兜底:JS 会写 #root 行内样式;此处仅约束 html/body。 + * 安卓 Chrome 普通页需「全屏 + orientation.lock」才能系统级锁竖屏。 */ html.phone-portrait-lock.phone-landscape, html.phone-portrait-lock.phone-landscape body { @@ -50,20 +50,22 @@ html.phone-portrait-lock.phone-landscape body { inset: 0; } -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; +.portrait-lock-hint { + position: fixed; + left: 50%; + bottom: calc(64px + env(safe-area-inset-bottom, 0px)); + transform: translateX(-50%); + z-index: 10000; + margin: 0; + padding: 10px 16px; + border: 1px solid rgba(240, 185, 11, 0.45); + border-radius: 999px; + background: rgba(11, 14, 17, 0.92); + color: var(--accent); + font-size: 13px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); } button,