Lock Android portrait via fullscreen then orientation.lock.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-27 14:30:43 +08:00
parent ed96b3ffb0
commit 0e3a8b08ec
5 changed files with 226 additions and 44 deletions
+11
View File
@@ -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 — 手机端真·竖屏(禁横屏布局)
### 变更
+8 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#0b0e11" />
<meta name="color-scheme" content="dark" />
@@ -13,6 +13,13 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="比特骆驼" />
<meta name="application-name" content="比特骆驼" />
<!-- 安卓 WebView / X5 / UC:声明竖屏(Chrome 仍需 JS 全屏+lock 或安装 PWA -->
<meta name="screen-orientation" content="portrait" />
<meta name="x5-orientation" content="portrait" />
<meta name="x5-page-mode" content="app" />
<meta name="browsermode" content="application" />
<meta name="full-screen" content="yes" />
<meta name="x5-fullscreen" content="true" />
<meta
name="description"
content="比特骆驼自动化对冲系统:ETH 永续+期权对冲监控与策略控制台,支持电脑与平板安装为独立应用。"
+2 -2
View File
@@ -5,8 +5,8 @@
"start_url": "/plan",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "fullscreen", "minimal-ui"],
"orientation": "portrait-primary",
"display_override": ["fullscreen", "standalone", "minimal-ui"],
"orientation": "portrait",
"background_color": "#0b0e11",
"theme_color": "#0b0e11",
"lang": "zh-CN",
+187 -25
View File
@@ -1,8 +1,10 @@
/**
* 手机端竖屏锁定:
* 1) 能锁系统方向时直接 lock(安卓 PWA / 部分全屏环境)
* 2) 锁不住时,横屏仍强制按竖屏布局渲染(不进入横屏 UI
* 安卓竖屏锁定:
* - 已安装 PWA / 部分 WebView:直接 orientation.lock
* - Chrome 普通标签页:必须先全屏再 lock(系统策略
* - 仍失败:用 JS 把界面强制按竖屏坐标系渲染(程序不进横屏布局)
*/
import { isStandaloneDisplay } from "./install";
type OrientationLockType =
| "any"
@@ -14,68 +16,228 @@ type OrientationLockType =
| "landscape-primary"
| "landscape-secondary";
let osLocked = false;
function isAndroid(): boolean {
return /Android/i.test(navigator.userAgent || "");
}
function isPhoneLike(): boolean {
if (typeof window === "undefined") return false;
const ua = navigator.userAgent || "";
if (/Android.+Mobile|iPhone|iPod|Windows Phone/i.test(ua)) return true;
if (/iPhone|iPod|Windows Phone/i.test(ua)) return true;
if (/Android/i.test(ua)) {
if (/Mobile/i.test(ua)) return true;
return Math.min(window.screen.width, window.screen.height) <= 600;
}
const coarse = window.matchMedia("(pointer: coarse)").matches;
const shortSide = Math.min(window.screen.width, window.screen.height);
return coarse && shortSide <= 520;
}
function isLandscape(): boolean {
return window.matchMedia("(orientation: landscape)").matches;
return window.innerWidth > window.innerHeight;
}
async function tryLockPortrait(): Promise<boolean> {
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<boolean> {
const orient = screen.orientation as ScreenOrientation & {
lock?: (orientation: OrientationLockType) => Promise<void>;
};
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<boolean> {
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<boolean> {
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,
});
}
+18 -16
View File
@@ -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,