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,