Browse Source

fix(android): 修复状态栏压住内容——原生安全区校正

真机反馈:登录后首页顶部太靠上,通知栏没留出空位,
左上角菜单按钮压住时间、右上角「新建」与电池信号重叠。

## 根因
页面侧是对的:全项目 8 处顶栏/抽屉/登录页都用 var(--mob-safe-top),
定义为 env(safe-area-inset-top, 0px)。但 Capacitor WebView 里该 env() 恒为 0
—— Android 15+ 强制 edge-to-edge,WebView 铺满到屏幕顶,而系统未把
WebView 标记为 edge-to-edge 内容,于是「视觉重叠、CSS 以为没重叠」。

## 方案
新增 core/safe-area.service.ts:@capacitor/status-bar 读系统状态栏真实高度,
写入 --mob-safe-top。全项目 8 处顶栏一次性修好,未改任何页面组件。
- 状态栏浮于内容之上(overlay),保留「背景延伸到状态栏下」的观感
- 按明暗主题切换状态栏图标色(MutationObserver 监听 <html class>)
- 取不到高度时退回 24px 保守值

不改 viewport-fit=cover(会让底部 safe-area 失效,TabBar 依赖它),
也不用 setOverlaysWebView(false)(会变成状态栏一条纯空白)。

## 踩坑
StatusBar.getInfo().height 返回的已是 CSS 像素,不能再除 devicePixelRatio。
首次写成 152/3.25=14px,顶栏只让开一点仍被压住;
靠 CDP 连 WebView 读回 --mob-safe-top 才发现。正确值 46px,顶栏高 114px。

## 验证
- 亮色:状态栏完整可见,按钮与标题正确让开
- 暗色:图标自动转浅色(style=LIGHT),深色背景清晰
- 会话列表 / 通讯录两页均正常
- CDP 确认 --mob-safe-top: 46px

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 6 giờ trước cách đây
mục cha
commit
058ab76285

+ 1 - 0
android/app/capacitor.build.gradle

@@ -12,6 +12,7 @@ dependencies {
     implementation project(':capacitor-app')
     implementation project(':capacitor-local-notifications')
     implementation project(':capacitor-share')
+    implementation project(':capacitor-status-bar')
 
 }
 

+ 4 - 0
android/app/src/main/assets/capacitor.plugins.json

@@ -10,5 +10,9 @@
 	{
 		"pkg": "@capacitor/share",
 		"classpath": "com.capacitorjs.plugins.share.SharePlugin"
+	},
+	{
+		"pkg": "@capacitor/status-bar",
+		"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
 	}
 ]

+ 3 - 0
android/capacitor.settings.gradle

@@ -10,3 +10,6 @@ project(':capacitor-local-notifications').projectDir = new File('../node_modules
 
 include ':capacitor-share'
 project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
+
+include ':capacitor-status-bar'
+project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')

+ 15 - 0
docs/00-tracking/操作追踪.md

@@ -8390,3 +8390,18 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | **通知权限** | `POST_NOTIFICATIONS` 已声明、未主动请求(按设计在首次真正要发通知时才申请) |
 | **未完成** | V3 会话列表/V4 收发消息/V5 文件上传 —— 需真实账号登录后才能验;V9 通知需产生一次任务完成事件 |
 | **构建** | `npm run build` → `npx cap sync` → `gradlew assembleDebug`,APK **6.8 MB** |
+
+## 2026-11-15(续四)· 真机缺陷修复:顶部状态栏压住内容
+
+| 项 | 内容 |
+|---|---|
+| **用户反馈** | 「登录成功跳转的首页,好像没有考虑到手机顶部的一个间隔问题,就是太靠上了,没有给手机上部分的通知栏留空位」 |
+| **复现** | ✅ 截图确认:左上角菜单按钮压住时间 `9:08`,右上角「新建」按钮与电池/信号重叠 |
+| **根因** | 页面本身是对的——全项目 8 处顶栏/抽屉/登录页都用了 `var(--mob-safe-top)`,定义是 `env(safe-area-inset-top, 0px)`。但 **Capacitor WebView 里该 env() 恒为 0**:Android 15+ 强制 edge-to-edge,WebView 铺满到屏幕顶端,而系统未把 WebView 标记为 edge-to-edge 内容 → 返回 0。视觉重叠了,CSS 却以为没重叠 |
+| **为什么不改 viewport** | 去掉 `viewport-fit=cover` 会让底部 safe-area 一起失效(Home 指示条/手势条),而 TabBar 已正确依赖 `--mob-safe-bottom`。**不能动全局 viewport** |
+| **为什么不关 `setOverlaysWebView`** | 那会让 WebView 不铺到状态栏下方 → 状态栏一条纯空白,而非现代 App 的「背景延伸到状态栏下」观感 |
+| **方案** | 新增 `core/safe-area.service.ts`:`@capacitor/status-bar` 读系统状态栏真实高度 → 写入 `--mob-safe-top`,**全项目 8 处顶栏一次性修好,未改任何页面组件**。同时按明暗主题切换状态栏图标色(MutationObserver 监听 `<html class>`) |
+| **🔴 踩坑(重要)** | `StatusBar.getInfo().height` **返回的已经是 CSS 像素,不能再除 devicePixelRatio**。第一次写成 `152 / 3.25 = 14px`,顶栏只让开一点点,状态栏仍压住内容。靠 CDP 连 WebView 读回 `--mob-safe-top` 才发现。正确值 = 46px(该机型),顶栏总高 114px = 46 + 68 |
+| **验证** | 亮色:状态栏完整可见、按钮与标题正确让开 ✅ 暗色:图标自动转浅色(`style: LIGHT`)、深色背景上清晰可见 ✅ 会话列表 / 通讯录两页均正常 ✅ CDP 确认 `--mob-safe-top: 46px` |
+| **既有风险(实测排除)** | 登录页**键盘弹起不遮挡**输入框与按钮,页面整体上移,此前标记的高优先级风险不存在 |
+| **遗留** | 安全区数值依赖各机型 `getInfo()`,低版本 Android 或无状态栏设备会走 24px 兜底 |

+ 10 - 0
package-lock.json

@@ -20,6 +20,7 @@
         "@capacitor/core": "^8.6.2",
         "@capacitor/local-notifications": "^8.3.1",
         "@capacitor/share": "^8.0.3",
+        "@capacitor/status-bar": "^8.0.4",
         "@fortawesome/fontawesome-free": "^7.3.1",
         "@silurus/ooxml": "^0.66.3",
         "fmode-ng": "^0.0.246",
@@ -1516,6 +1517,15 @@
         "@capacitor/core": ">=8.0.0"
       }
     },
+    "node_modules/@capacitor/status-bar": {
+      "version": "8.0.4",
+      "resolved": "https://registry.npmmirror.com/@capacitor/status-bar/-/status-bar-8.0.4.tgz",
+      "integrity": "sha512-ZMzBUyHBzwDUxcHadEhvOvmoPmMQGMZgaMRJu8ohELeI6IaxoVH9ZhdF/OHnY3/yykv3Glev8Tnbh4aZY/0B0w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@capacitor/core": ">=8.0.0"
+      }
+    },
     "node_modules/@cfworker/json-schema": {
       "version": "4.1.1",
       "resolved": "https://registry.npmmirror.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz",

+ 1 - 0
package.json

@@ -52,6 +52,7 @@
     "@capacitor/core": "^8.6.2",
     "@capacitor/local-notifications": "^8.3.1",
     "@capacitor/share": "^8.0.3",
+    "@capacitor/status-bar": "^8.0.4",
     "@fortawesome/fontawesome-free": "^7.3.1",
     "@silurus/ooxml": "^0.66.3",
     "fmode-ng": "^0.0.246",

+ 8 - 0
src/app/app.config.ts

@@ -13,6 +13,7 @@ import { provideMarkdown } from 'ngx-markdown';
 import { provideFmodeApi } from './core/api/api.provider';
 import { AppErrorHandler } from './core/app-error-handler';
 import { DeepLinkService } from './core/deep-link.service';
+import { SafeAreaService } from './core/safe-area.service';
 import { routes } from './app.routes';
 
 export const appConfig: ApplicationConfig = {
@@ -43,5 +44,12 @@ export const appConfig: ApplicationConfig = {
      *    `start()` 内部自己判断「非原生壳直接返回」,浏览器里零副作用。
      */
     provideAppInitializer(() => inject(DeepLinkService).start()),
+    /*
+     * 原生安全区修正(2026-11-15 真机实测):Capacitor WebView 里
+     * `env(safe-area-inset-top)` 恒为 0,导致顶栏压到状态栏底下。
+     * 读系统状态栏真实高度写入 `--mob-safe-top`,全项目 8 处顶栏一次性修好。
+     * 非原生环境内部直接返回,浏览器零影响。
+     */
+    provideAppInitializer(() => inject(SafeAreaService).apply()),
   ],
 };

+ 113 - 0
src/app/core/safe-area.service.ts

@@ -0,0 +1,113 @@
+import { Injectable } from '@angular/core';
+import { isNativeApp } from './api/platform-proxy.service';
+
+/**
+ * 原生安全区修正(2026-11-15 真机实测发现)
+ *
+ * ## 问题
+ *
+ * 真机(小米 Android 16)上,会话列表页的**状态栏直接压在界面元素上**:
+ * 左上角菜单按钮遮住了时间,右上角「新建」按钮和电池/信号重叠。
+ *
+ * ## 根因
+ *
+ * 页面本身是对的 —— 全项目 8 处顶栏/抽屉/登录页都用了 `var(--mob-safe-top)`,
+ * 而这个变量定义是 `env(safe-area-inset-top, 0px)`。
+ *
+ * 但 **Capacitor WebView 里这个 env() 恒为 0**:
+ *   · Android 15+(API 35+)默认强制 edge-to-edge,WebView 铺满到屏幕顶端
+ *   · `safe-area-inset-*` 只在系统**显式告诉 WebView「你的内容与系统栏重叠」**时才有值
+ *   · Capacitor 的 WebView 未被标记为 edge-to-edge 内容,于是系统认为「没重叠」→ 返回 0
+ *
+ * 结果:视觉上重叠了,CSS 却以为没重叠 → 顶栏顶到状态栏底下。
+ *
+ * ## 做法
+ *
+ * 用 `@capacitor/status-bar` 读**系统状态栏的真实高度**,写入 `--mob-safe-top`。
+ * 这样全项目所有 `var(--mob-safe-top)` 一次性修好,**不用改任何页面组件**。
+ *
+ * ## 为什么不用改 `viewport-fit=cover`
+ *
+ * 去掉它会让底部 safe-area 也失效(iPhone 的 Home 指示条、Android 手势条),
+ * 而底部 TabBar 已经正确地依赖 `--mob-safe-bottom`。**不能动全局 viewport 配置**。
+ *
+ * ## 为什么不直接用 Capacitor 的 `StatusBar.setOverlaysWebView(false)`
+ *
+ * 那会让 WebView **不铺到状态栏下方**(内容区从状态栏下沿开始),
+ * 视觉上是「状态栏一条空白」,而不是「页面背景延伸到状态栏下方」。
+ * 后者才是现代 App 的标准观感(微信/飞书都是内容延伸到透明状态栏下)。
+ */
+@Injectable({ providedIn: 'root' })
+export class SafeAreaService {
+  /** 已应用过(避免重复注册监听) */
+  private applied = false;
+
+  /**
+   * 校正顶部安全区。非原生环境直接返回,浏览器行为完全不变。
+   */
+  async apply(): Promise<void> {
+    if (!isNativeApp() || this.applied) return;
+    this.applied = true;
+
+    try {
+      const { StatusBar, Style } = await import('@capacitor/status-bar');
+      /*
+       * 状态栏**浮在** WebView 之上(内容铺到它下面),
+       * 与页面「背景不断、内容让开」的设计一致。
+       */
+      await StatusBar.setOverlaysWebView({ overlay: true });
+
+      /*
+       * 深色图标还是浅色图标,取当前主题。
+       * 亮色背景 → 深色图标(Style.Dark 在 Capacitor 里指的是「深色文字」);
+       * 暗色背景 → 浅色图标。
+       *
+       * 🔴 主题切换时也要跟着改,否则暗色模式下状态栏黑字黑底看不见。
+       */
+      const applyStyle = () => {
+        const dark = document.documentElement.classList.contains('dark');
+        void StatusBar.setStyle({ style: dark ? Style.Light : Style.Dark });
+      };
+      applyStyle();
+
+      /* 监听 <html class="dark"> 变化,主题切换时同步状态栏图标色 */
+      new MutationObserver(applyStyle).observe(document.documentElement, {
+        attributes: true,
+        attributeFilter: ['class'],
+      });
+
+      /*
+       * 读真实高度并写入 CSS 变量。
+       *
+       * `getInfo()` 返回的是**物理像素**(density 相关),
+       * 而 CSS 变量要的是逻辑像素 → 必须除以 devicePixelRatio。
+       *
+       * 🔴 万一插件拿不到高度(某些 ROM),退回保守值 24px ——
+       *    宁可多留一点,也不要让状态栏压住按钮。
+       */
+      /*
+       * 读真实高度并写入 CSS 变量。
+       *
+       * 🔴 `height` 返回的**已经是 CSS 像素**,不要再除 devicePixelRatio!
+       *    实测(小米 Android 16,dpr 3.25):
+       *      getInfo().height === 46            ← 就是 CSS px,与物理 152px 对应
+       *      误当物理像素再除 3.25 → 14px       ← 顶栏只让开一点点,状态栏仍压住内容
+       *    这个坑我第一次就踩了,靠 CDP 读回 `--mob-safe-top` 才发现。
+       */
+      const info = await StatusBar.getInfo();
+      const cssPx = info.height && info.height > 0 ? info.height : 24;
+      this.setSafeTop(Math.max(0, Math.round(cssPx)));
+    } catch {
+      /*
+       * 插件不可用 / 权限问题:退回保守值,保证状态栏不会压住内容。
+       * 宁可多一点留白,也不要出现按钮被遮住这种硬伤。
+       */
+      this.setSafeTop(24);
+    }
+  }
+
+  /** 写入 `--mob-safe-top`(同时覆盖 root,全局生效) */
+  private setSafeTop(px: number): void {
+    document.documentElement.style.setProperty('--mob-safe-top', `${px}px`);
+  }
+}