ソースを参照

feat(mobile): 第三轮修复 + 登录后权限引导 + 进度文档

## 修复 4 个真机问题

### 1. PDF 预览一直转圈(已定位根因)
public/pdf.worker.js 是降级到 pdfjs 4.10.38 之前的旧版(6.4.299,1,264,342 字节),
主包 4.10 + worker 6.4 → 协议不匹配 → getDocument().promise 既不 resolve 也不 reject。
- 重新生成 worker(1,375,838,已校验)
- copy-pdf-worker.mjs 加大小断言,不一致直接退出(这坑已踩两次)
- pdf-preview.ts 加 20 秒超时,超时给明确错误而非无限转圈
- file-preview.ts 补传 reloadTick(此前「重新加载」对 PDF 无效)

### 2. 顶栏标题遮住返回按钮
.m-title 绝对定位铺满顶栏,左右按钮无 z-index,标题溢出画在按钮之上;
副标题 <small> 更是没有任何宽度约束。
- m-app-bar.ts 改为 flex 三栏布局,标题物理上不可能越界
- rightCount / titleRight 标记 deprecated(保留不破坏调用方)

### 3. 预览按钮点击弹 tooltip
底座 ui-tooltip 用 (focusin)="show()",触屏点击把焦点移到按钮即触发。
- tooltip.ts 加 disabled input(向后兼容,桌面端零变化)
- file-preview.ts 8 处 tooltip 统一传 [disabled]="touchUi"

### 4. 权限开启后返回 App 无反馈
granted 是普通字段且永不清除;request() 也缓存短路(会导致连系统弹窗都不弹)。
- granted 改为 signal;新增 refreshGranted()
- request() 仅在 granted===true 时短路
- init() 注册 appStateChange,回前台自动刷新,false→true 时 toast 提示
- permission-guide 不再立即 markPrompted;mine.ts 订阅 signal

## 新增:登录后通知权限引导弹层
mobile/components/permission-guide/:说明用途、可随时关闭、「以后再说」、
只问一次(localStorage['fmode.permPrompted'])。挂在 mobile-shell,覆盖所有登录路径。

## 文档
- docs/00-tracking/当前进度-给AI接手.md:AI 接手入口(时间线/踩坑/文件索引/待办)
- docs/00-tracking/2026-11-15-真机验证完整记录.md:24 项逐条结果 + 第三轮
- CLAUDE.md 顶部加进度摘要

## 状态
tsc ✅ / build ✅ / 第三轮修复**尚未装机复验**

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 4 時間 前
親
コミット
d1ad874522

+ 39 - 0
CLAUDE.md

@@ -1,5 +1,44 @@
 # Claude Code 项目规则(FmodeStudio 规范包 · 阶段视图 2/4:设计建模)
 
+---
+
+## 🔴🔴 当前进度(2026-11-15 14:00,AI 接手请先读这一节)
+
+> **完整记录**:`docs/00-tracking/当前进度-给AI接手.md`(30 秒版,含时间线/踩坑/文件索引)
+> **测试详情**:`docs/00-tracking/2026-11-15-真机验证完整记录.md`(24 项逐条 + 第三轮)
+
+**现在做到哪了**:Android 包已产出并可安装,真机验证 **21/24 项通过**。
+核心功能(登录/收发消息/传文件/Word 预览/通知/深链)可用。
+**第三轮 4 个修复已写完代码,但还没装机复验。**
+
+**下一步(按优先级)**:
+
+1. 🔴 **装机复验第三轮 4 项**:PDF 预览、顶栏标题不遮返回键、
+   点预览按钮不弹 tooltip、权限开启后返回有 toast 反馈
+2. 🔴 未测 3 项:断网发消息、快速连点、长时后台(>10 分钟)
+3. 🟡 **待诊断**:第一个会话 3 个文件只显示 2 张卡片 ——
+   容器自己说「两条 MEDIA 没送达」,大概率是容器侧发送失败,
+   需拉 Matrix 房间事件确认(App 内 token 只在内存,CDP 取不到,需临时打日志)
+4. 🔴 **需要产品决策**:App 在后台收不到通知(Android 16 后台 JS 被暂停),
+   要解决必须做服务端推送,缺「后端监听 Matrix → 调推送 API」这一环,需平台配合
+5. 🟡 外部分享(微信/企微 → App)未实现,需自建 Android `ACTION_SEND`
+6. 🟡 等平台修复 `/api/apig/message` 多值 CORS(App 侧已用云函数代理绕过)
+
+**⚠️ 最容易重复踩的坑**:`public/pdf.worker.js` 是构建产物,`npm run build`
+**不会**重新生成。改动 pdfjs 版本后必须跑 `node scripts/copy-pdf-worker.mjs`
+(现在带大小断言)。这个坑已经踩过**两次**,两次都表现为「PDF 一直转圈」。
+
+**Android 构建命令**(Gradle wrapper 下载会超时,用本地 gradle):
+
+```bash
+export ANDROID_HOME="E:/coding-environment/AndroidSDK"
+node scripts/copy-pdf-worker.mjs
+npm run build && npx cap sync android
+cd android && ~/.gradle/wrapper/dists/gradle-8.14.3-bin/b5900753zu8plw6wgn0i82wad/gradle-8.14.3/bin/gradle assembleDebug
+```
+
+---
+
 > 本文件为 FmodeStudio 规范包的**客户项目入口**(自 `stages/stage-2-设计建模-CLAUDE.md` 派生并预填本项目信息)。
 > **阶段视图机制**:黄金规则 11 条 + AI 协作义务 + 环节-Skill 调用表**全阶段常驻**(本文件正文各阶段一致);仅「六、模块规则」@import 区块按阶段裁剪。阶段切换由 AI 自动执行——条件达成后 AI 直接编辑本文件「六、模块规则」区块为目标阶段清单并留痕,无需用户手动操作文件(清单与条件见包内《阶段视图切换指南.md》)。
 > **规范母版(唯一权威源)**:随包 rules/(21 模块全集)、templates/(25 模板)、.claude/skills/(60 技能)、ui-kit/(官方组件底座)。客户项目内不修改 rules/ 内容;踩坑经验由交付方回写母版后随新版本同步。

+ 246 - 0
docs/00-tracking/2026-11-15-真机验证完整记录.md

@@ -0,0 +1,246 @@
+# 2026-11-15 真机验证完整记录
+
+> **本文档是「明天/后天继续」的第一入口。**
+> 读它就能知道:测了什么、哪些过了、哪些挂了、哪些还没测。
+
+---
+
+## 〇、一句话现状
+
+**Android App 已能装、能登录、能收发消息、能传文件、能收通知。**
+**核心功能全部可用;剩余 3 项未测;1 项产品级限制(后台收不到通知)需决策。**
+
+---
+
+## 一、测试环境
+
+| 项 | 值 |
+|---|---|
+| 设备 | 小米 25060RK16C |
+| 系统 | Android 16(API 36) |
+| WebView | Chrome 143.0.7499.192 |
+| App 包名 | `cn.fmode.app` |
+| 构建 | Capacitor 8.6.2 · targetSdk 36 · minSdk 24 |
+| APK | `android/app/build/outputs/apk/debug/app-debug.apk`(约 6.8 MB) |
+| 验证手段 | adb 截图 / `uiautomator dump` / **CDP 连 WebView 调试端口** / logcat |
+
+### 复现测试环境
+
+```bash
+# 构建 + 安装
+export ANDROID_HOME="E:/coding-environment/AndroidSDK"
+npm run build && npx cap sync android
+cd android && ~/.gradle/wrapper/dists/gradle-8.14.3-bin/b5900753zu8plw6wgn0i82wad/gradle-8.14.3/bin/gradle assembleDebug
+/e/coding-environment/adb/adb install -r app/build/outputs/apk/debug/app-debug.apk
+
+# 连 WebView 调试(用于读内部状态 / 精确点击)
+PID=$(adb shell ps -A | grep cn.fmode.app | awk '{print $2}' | head -1)
+adb forward tcp:9300 localabstract:webview_devtools_remote_$PID
+```
+
+> 🔴 Gradle wrapper 配的是 `-all` 会下载超时,本地只有 `-bin` 完整缓存,
+> 所以上面直接用本地 gradle 可执行文件。
+
+---
+
+## 二、测试结果总表(24 项)
+
+图例:✅ 通过 | 🔧 发现问题并已修复 | ⏸ 未测 | ⚠️ 有已知限制
+
+### 第 1 组:基础导航
+
+| # | 项目 | 结果 | 说明 |
+|---|---|---|---|
+| 1.1 | 四个 Tab 切换(会话/通讯录/工作台/我的) | ✅ | 全部正常渲染,状态栏位置正确 |
+| 1.2 | 返回键行为 | 🔧 | **原来完全没有接管物理返回键**。表现为:关预览时把会话也退了、偶发白屏。已修 |
+| 1.3 | 抽屉侧滑 | 🔧 | **状态栏图标在白色抽屉上完全看不见**。根因:Capacitor `Style.Light/Dark` 语义写反。已修 |
+
+### 第 2 组:会话与收发消息
+
+| # | 项目 | 结果 | 说明 |
+|---|---|---|---|
+| 2.1 | 进入会话、历史消息加载 | ✅ | 附件卡片、时间戳正常 |
+| 2.2 | 发送文字 | ✅ | 即发即显,输入框清空 |
+| 2.3 | 等容器回复 + ✅ reaction 停止 | ✅ | 顶栏从「正在回复…」正确回到「在线」,计时器停止,**没有一直转圈** |
+| 2.4 | 长文本 + 键盘遮挡 | ✅ | 键盘弹起时输入框与发送按钮都在键盘上方 |
+| 2.5 | **草稿按会话隔离**(本轮修复项) | ✅ | A 打草稿 → 切 B(B 为空,不串)→ 回 A(草稿还在) |
+
+### 第 3 组:文件上传与预览
+
+| # | 项目 | 结果 | 说明 |
+|---|---|---|---|
+| 3.1 | 上传图片 | ✅ | 70B PNG 上传成功,容器确认收到并识别 |
+| 3.1b | 上传 PDF | ✅ | 容器完整解析了 PDF 结构 |
+| 3.2 | 图片预览 | ✅ | 灯箱打开,下载/新标签/关闭按钮正常 |
+| 3.3 | JSON 预览 | ✅ | 页内渲染,带格式化 |
+| 3.3b | Markdown 预览 | ✅ | 标题层级、引用块、代码高亮、列表全部正确 |
+| 3.4 | **PDF 预览** | ⏸ | **未测**。PDF 已上传成功,但没点开预览(项目历史上 pdfjs 在此处崩过,需重点验证) |
+
+### 第 4 组:切后台与恢复
+
+| # | 项目 | 结果 | 说明 |
+|---|---|---|---|
+| 4.1 | 切后台 90 秒后回来 | ✅ | 状态完整保留,不重新加载、不闪屏 |
+| 4.2 | 后台期间容器回复,回来能同步 | ✅ | 消息已更新 |
+| 4.3 | 长时后台 | ⏸ | 未单独测(>10 分钟) |
+| 4.4 | 跨设备同步 | ⏸ | 用户裁决不做(无第二设备) |
+
+### 第 5 组:通知
+
+| # | 项目 | 结果 | 说明 |
+|---|---|---|---|
+| 5.1 | 首次通知权限弹窗 | 🔧 | 原本权限申请会**顺带跳「闹钟和提醒」设置页**。根因:插件 `isExactNotification` 默认 true。已修 |
+| 5.2 | 任务完成通知实际弹出 | 🔧 | 原本**通知静默丢失**:`schedule()` 返回成功但通知栏空白。根因:渠道未创建(Android 8+ 静默丢弃)。已修 |
+| 5.3 | 点击通知跳转 | 🔧 | 原本**点击拉不起 App**。根因:插件用 `ACTION_VIEW` + 自定义 scheme 构造 PendingIntent,被系统当外部链接解析。已改用自建原生插件 |
+| 5.4 | 登录后权限引导弹层 | ✅ | 新增。说明用途(任务完成/新回复)、可随时关闭、有「以后再说」,只问一次 |
+
+### 第 6 组:深链
+
+| # | 项目 | 结果 | 说明 |
+|---|---|---|---|
+| 6.1 | 深链唤起(App 在运行) | ✅ | `adb am start -a VIEW -d "fmode://contacts"` → 正确跳到通讯录 |
+| 6.2 | 通知点击触发深链 | ✅ | 冷启动路径验证通过,点通知 → 拉起 App → 跳通讯录 |
+
+### 第 7 组:异常与边界
+
+| # | 项目 | 结果 | 说明 |
+|---|---|---|---|
+| 7.1 | 断网发消息 | ⏸ | **未测** |
+| 7.2 | 断网恢复自动重连 | ⏸ | **未测** |
+| 7.3 | 快速连点防重复 | ⏸ | **未测** |
+
+### 其他
+
+| 项目 | 结果 | 说明 |
+|---|---|---|
+| 登录态持久(杀进程重开) | ✅ | 仍保持登录 |
+| 登录发验证码(云函数代理) | ✅ | 不再受多值 CORS 影响 |
+| 退出登录二次确认 | ✅ | 防误触有效 |
+
+---
+
+## 三、本轮修复的问题清单(7 个)
+
+| # | 问题 | 根因 | 修复 |
+|---|---|---|---|
+| 1 | 状态栏压住顶栏内容 | Capacitor WebView 里 `env(safe-area-inset-top)` 恒为 0 | `safe-area.service.ts` 读原生高度写入 CSS 变量 |
+| 2 | 抽屉打开时状态栏图标不可见 | `Style.Light/Dark` 指「背景明暗」不是「图标颜色」,写反了 | 改正取值 |
+| 3 | 物理返回键未接管 → 退两层 / 白屏 | 无 `backButton` 监听,完全交给 WebView goBack() | `back-button.service.ts` 三级处理 |
+| 4 | 通知权限后跳「闹钟和提醒」 | `isExactNotification` 默认 true(插件源码 `LocalNotification.kt:185`) | 改用自建插件 |
+| 5 | 通知静默丢失 | 渠道未创建,Android 8+ 静默丢弃 | 改用自建插件,内部 createChannel |
+| 6 | 点击通知拉不起 App | 插件用 `ACTION_VIEW` + 自定义 scheme,被当外部链接解析 | 改用自建插件,显式 ComponentName,深链走 extra |
+| 7 | 登录发验证码随机失败 | 网关两层各加一次 ACAO 头,约 33% 概率多值被拦 | 云函数 `fmode-login-proxy` 代理 |
+
+---
+
+## 四、未解决的问题与产品级限制
+
+### ⚠️ 限制 1:App 在后台收不到通知(**需要产品决策**)
+
+**实测结论**:Android 16 上 WebView 的 JS 在后台被彻底暂停,`TaskPulseService`
+轮询停摆 → 任务完成事件根本没机会执行 → 没有通知可发。
+
+| 场景 | 能否收到 |
+|---|---|
+| App 在前台 | ✅ |
+| 切走后几秒内 | ⚠️ 不稳定 |
+| 后台超过 1-2 分钟 | ❌ |
+| App 被杀 / 重启后 | ❌ |
+
+**要解决必须做服务端推送**,链路是:
+```
+容器回复 → Matrix 事件 → 【缺失:某个后端监听】 → 调推送 API → 推到设备
+```
+「某个后端」需要平台开 webhook,或自部署常驻服务监听 Matrix。**均需平台配合。**
+
+**可选方向**:
+- A. 极光 JPush(官方 Cordova 插件 `jpush/jpush-phonegap-plugin` v6.3.0,923★,仍在维护)
+  —— 需厂商通道申请(华为需企业认证,约 1-2 周)
+- B. 自建推送服务
+- C. 不做,产品上明确「提醒需 App 保持运行」
+
+### ⚠️ 限制 2:平台网关多值 CORS 未根治
+
+`/api/apig/message` 仍会随机返回多条 `Access-Control-Allow-Origin`。
+App 侧已走云函数代理绕开(`fmode-login-proxy` / `zpus2KjtFH`),
+但平台侧修复仍是治本。需求文档:`docs/03-interface/平台CORS多值修复需求-20261115.md`
+
+### ⚠️ 限制 3:外部分享未实现
+
+「微信/企微 → 分享文件 → App 某个会话」**没有任何现成 Capacitor 插件能做**,
+必须自建 Android `ACTION_SEND` + FileProvider。设计已出:
+`docs/02-design/plans/2026-11-15-外部分享接收设计.md`(约 4.5 人天)
+
+### 待观察:会话列表预览显示首条用户消息
+
+列表预览显示的是会话**第一条**用户消息而非最新消息(如显示「帮我用 html 做一个…」
+而不是「收到。App-test-1115…」)。刷新后会变,行为不一致。**未确认是否为预期设计。**
+
+---
+
+## 五、下次继续的检查清单
+
+- [ ] 测 **3.4 PDF 预览**(重点,历史上有崩溃记录)
+- [ ] 测 **7.1/7.2 断网**场景
+- [ ] 测 **7.3 快速连点**
+- [ ] 测 **4.3 长时后台**(>10 分钟)
+- [ ] 决策:后台通知问题走 A/B/C 哪条路
+- [ ] 决策:外部分享是否立项
+- [ ] 确认:会话预览显示首条消息是否为预期
+
+---
+
+## 六、相关文档
+
+| 文档 | 内容 |
+|---|---|
+| `docs/02-design/plans/2026-11-14-手机端UX优化与Android打包方案.md` | 总方案(阶段 A/B/C 规划) |
+| `docs/02-design/plans/2026-11-15-Android打包实施记录.md` | 打包实施细节 |
+| `docs/02-design/plans/2026-11-15-外部分享接收设计.md` | 外部分享设计方案 |
+| `docs/03-interface/平台CORS多值修复需求-20261115.md` | 给平台的 CORS 修复需求 |
+| `docs/04-quality/review/2026-11-14-手机端UX方案评审.md` | 外部 UX 方案真伪判定 |
+| `docs/00-tracking/操作追踪.md` | 逐条操作留痕 |
+
+---
+
+## 七、第三轮修复(2026-11-15 晚,**代码完成、待复验**)
+
+用户又报 4 个问题,全部已从代码层面修复,**但尚未装机验证**。
+
+| # | 问题 | 根因 | 状态 |
+|---|---|---|---|
+| 1 | PDF 预览一直转圈 | `public/pdf.worker.js` 是降级前的旧版 6.4.299(1,264,342 字节),主包是 4.10.38,协议不匹配导致 promise 永不 resolve | ✅ 已重新生成(1,375,838)+ 加断言 + 加 20s 超时 |
+| 2 | 顶栏标题遮住返回按钮 | `.m-title` 绝对定位铺满顶栏,按钮无 z-index,标题溢出画在按钮上;副标题 `<small>` 无宽度约束 | ✅ 改为 flex 三栏布局 |
+| 3 | 预览按钮点击弹提示 | 底座 `ui-tooltip` 的 `focusin` 被触屏点击触发 | ✅ 加 `disabled` input,移动端传 true |
+| 4 | 权限开启后返回无反馈 | `granted` 缓存永不失效 + `request()` 也缓存短路 + 无回前台监听 | ✅ 改 signal + `refreshGranted()` + `appStateChange` 监听 |
+
+### 验证记录(需补)
+
+| # | 验收点 | 结果 |
+|---|---|---|
+| 1 | PDF 能预览 | ⏸ 待验 |
+| 2 | 标题不遮返回键 | ⏸ 待验 |
+| 3 | 点预览按钮不弹提示 | ⏸ 待验 |
+| 4 | 权限状态能刷新 | ⏸ 待验 |
+
+### 新增:登录后通知权限引导
+
+`src/app/mobile/components/permission-guide/permission-guide.ts`,挂在 `mobile-shell` 上。
+内容:说明用途(任务完成/新回复)、列两条具体价值、说明可随时在「我的 → 通知」关闭、
+两个按钮「以后再说 / 开启通知」。只问一次(`localStorage['fmode.permPrompted']`)。
+
+**已真机截图确认弹层渲染正确**,但「开启 → 跳设置 → 返回 → 提示成功」这条闭环待验。
+
+### 待诊断:3 个文件只显示 2 张卡片
+
+**现象**:第一个会话正文列 3 个链接(table.html / .docx / .pdf),卡片区只有前两个。
+
+**已排查**:
+- 卡片在 `app-assistant-files` 组件,数据来自 `assistant-turn.ts` 的 `collectFiles()`
+- `table.html` 出现两次是因为它在**两个不同回合**各发了一次,不是去重失效
+- **容器自己在消息里说「上一条消息里两条 MEDIA 应该没送达」**
+
+**当前判断**:**大概率是容器侧发送失败**,Matrix 附件事件根本没到达 App。
+
+**下一步**:拉 Matrix 房间事件确认 PDF 的 `m.file` 是否存在。
+App 内 Matrix token 只在内存,CDP 拿不到,需临时在 `matrix-client.service.ts` 打日志。

+ 208 - 0
docs/00-tracking/当前进度-给AI接手.md

@@ -0,0 +1,208 @@
+# 项目当前进度(AI 接手必读)
+
+> **最后更新:2026-11-15 14:00**
+> **用途:任何 AI 接手本项目时,先读这里,30 秒知道现在做到哪了。**
+> 详细验证记录见 `docs/00-tracking/2026-11-15-真机验证完整记录.md`
+
+---
+
+## 一、当前阶段
+
+**Android App 已落地,真机验证 21/24 项通过;第三轮修复已完成代码,待真机复验。**
+
+| 阶段 | 状态 |
+|---|---|
+| 阶段 A:手机端 UX 优化 | ✅ 完成并合并(2026-11-14) |
+| 阶段 B:Capacitor 打包 Android | ✅ 已产出可安装 APK |
+| 阶段 C:推送与分享 | 🟡 深链完成;**推送受限于服务端链路**;分享未实现 |
+| 第三轮:真机问题修复 | 🟡 **代码已完成,未装机复验** |
+
+---
+
+## 二、能跑起来的东西
+
+```bash
+# 构建 APK(注意:Gradle wrapper 下载会超时,必须用本地 gradle)
+export ANDROID_HOME="E:/coding-environment/AndroidSDK"
+node scripts/copy-pdf-worker.mjs      # 生成 pdf worker(有断言,会校验)
+npm run build && npx cap sync android
+cd android && ~/.gradle/wrapper/dists/gradle-8.14.3-bin/b5900753zu8plw6wgn0i82wad/gradle-8.14.3/bin/gradle assembleDebug
+
+# 安装
+/e/coding-environment/adb/adb install -r android/app/build/outputs/apk/debug/app-debug.apk
+```
+
+**已真机验证可用的功能**:
+登录(含验证码)· 会话列表 · 收发消息 · 文件上传 · 图片/JSON/Markdown/Word 预览 ·
+切后台恢复 · 登录态持久 · 深链 `fmode://` · 系统通知(前台)· 通知权限引导
+
+---
+
+## 三、本轮(第三轮)改了什么 —— 未复验
+
+用户提出的 4 个问题,代码层面已全部修复,**但还没装机验证**。
+
+### 1. PDF 预览一直转圈(根因已确认)
+
+**根因**:`public/pdf.worker.js` 是**降级到 pdfjs 4.10.38 之前**生成的旧版(6.4.299)。
+主包 4.10 + worker 6.4 → 协议不匹配 → `getDocument().promise` 既不 resolve 也不 reject → 永远转圈。
+用户看到的报错原文:「API 版本 6.4.299 不匹配当前工作的版本 4.10.38」。
+
+| 文件 | 修复前 | 修复后 |
+|---|---|---|
+| `public/pdf.worker.js` | 1,264,342(6.4.299) | **1,375,838(4.10.38)** |
+
+**改动**:
+- `scripts/copy-pdf-worker.mjs`:复制后**加断言**校验大小,不一致直接退出(这坑已踩两次)
+- `pdf-preview.ts`:`getDocument` 加 **20 秒超时**,超时给明确错误而不是无限转圈
+- `file-preview.ts`:补传 `[reloadTick]`(之前「重新加载」对 PDF 无效)
+
+### 2. 顶栏标题太长遮住返回按钮
+
+**根因**:`.m-title` 是 `position:absolute` 铺满顶栏,而左右按钮是普通 flex 子项**没有 z-index**。
+绝对定位层默认绘制在普通流之后 → 标题任何溢出都画在按钮上。
+`max-width:70%` 挡不住,因为它是相对标题层算的;副标题 `<small>` 更是没有宽度约束。
+
+**改动**:`m-app-bar.ts` 改为 **flex 三栏** `[左 44] [中间 flex:1 min-width:0] [右 auto]`。
+标题物理上不可能越界。`rightCount` / `titleRight` 标记为 deprecated(保留不破坏调用方)。
+
+### 3. 预览按钮点击弹提示(PC 的 hover 逻辑被触屏误触发)
+
+**根因**:底座 `ui-tooltip` 用 `(focusin)="show()"`,触屏点按钮时 WebView 把焦点移到按钮,
+触发 focusin → 200ms 后弹气泡且一直显示。
+
+**改动**:
+- `ui/components/tooltip/tooltip.ts`:加 `disabled` input(向后兼容,桌面端不传则零变化)
+- `file-preview.ts`:8 处 tooltip 统一传 `[disabled]="touchUi"`,`touchUi = isNativeApp()`
+
+### 4. 权限引导后返回 App 没有状态反馈
+
+**根因(两个独立 bug)**:
+- `granted` 是普通字段且**永不清除** → 用户去系统设置开了权限,回来还是 false
+- `request()` 也做缓存短路 → 只要 `isGranted()` 先跑过,`request()` 直接返回 false,**连系统弹窗都不弹**
+
+**改动**:
+- `app-notification.service.ts`:`granted` 改 **signal**;新增 `refreshGranted()`;
+  `request()` 只在 `granted === true` 时短路;`init()` 里注册 `appStateChange`,
+  回到前台自动刷新,从 false→true 时 toast「通知权限已开启」
+- `mine.ts`:通知状态改为订阅 service 的 signal
+- `permission-guide.ts`:跳系统设置后不再立即 markPrompted
+
+### 顺带完成(尚未提交为独立功能)
+
+- 登录后**一次性通知权限引导弹层** `mobile/components/permission-guide/`
+  (说明用途、可随时关闭、「以后再说」、只问一次,持久化在 `fmode.permPrompted`)
+
+---
+
+## 四、下次要做的(按优先级)
+
+### 🔴 高:装机复验第三轮修复
+
+| # | 验收点 | 怎么验 |
+|---|---|---|
+| 1 | PDF 能预览 | `stat public/pdf.worker.js` 应为 1,375,838;真机点开 PDF 应渲染出页面 |
+| 2 | 标题不遮返回键 | 第一个会话(标题「给我一个 HTML …」)截图确认返回按钮完整可见 |
+| 3 | 点预览按钮不弹提示 | 真机点「下载/新标签/复制/关闭」,不应出现任何 tooltip |
+| 4 | 权限状态能刷新 | 引导弹层 → 开启 → 跳系统设置 → 手动开 → 返回 App → 应提示「通知权限已开启」 |
+
+### 🔴 高:剩余 3 项未测
+
+1. **断网发消息 / 断网恢复**
+2. **快速连点防重复**
+3. **长时后台**(>10 分钟)
+
+### 🟡 中:待诊断 —— 3 个文件只显示 2 张卡片
+
+**现象**:第一个会话容器正文列了 3 个链接(table.html / .docx / .pdf),
+下方卡片区只有 **table.html + docx**,**PDF 卡片缺失**。
+
+**已排查的**:
+- 卡片渲染在 `app-assistant-files` 组件,数据来自 `turn().files`(`assistant-turn.ts` 的 `collectFiles`)
+- 有 URL 去重逻辑,但 `table.html` 仍出现两次 —— 因为它在**两个不同回合**里各发了一次(不是去重失效)
+- **容器自己在消息里说:「上一条消息里两条 MEDIA 应该没送达」**
+
+**当前判断**:**大概率是容器侧发送失败,不是 App 渲染问题**。
+Matrix 的附件事件根本没到达 App,所以没有可渲染的数据。
+
+**下一步怎么查**:需要拉 Matrix 房间事件确认(`m.file` 事件是否存在 PDF 那条)。
+App 里 Matrix token 只在内存中,CDP 取不到,需要:
+- 在 `matrix-client.service.ts` 临时打日志输出 `collectRoomEvents` 的结果,或
+- 用 `scripts/matrix-check.mjs` 的思路加一个 dump 脚本
+
+### 🟡 中:需要产品决策 —— App 在后台收不到通知
+
+**实测确认**:Android 16 上 WebView 的 JS 在后台被彻底暂停,`TaskPulseService` 轮询停摆,
+任务完成事件根本没机会执行 → 没有通知可发。
+
+| 场景 | 能否收到 |
+|---|---|
+| App 在前台 | ✅ |
+| 切走后几秒内 | ⚠️ 不稳定 |
+| 后台超过 1-2 分钟 | ❌ |
+| App 被杀 / 重启后 | ❌ |
+
+**要解决必须做服务端推送**,链路缺「某个后端监听 Matrix → 调推送 API」这一环,需平台配合。
+方向:A 极光 JPush(官方 Cordova 插件 `jpush/jpush-phonegap-plugin` v6.3.0,923★,需厂商通道)/
+B 自建推送服务 / C 不做,产品上明确说明。
+
+### 🟡 中:外部分享未实现
+
+「微信/企微 → App 某会话」需自建 Android `ACTION_SEND`,约 4.5 人天。
+设计已出:`docs/02-design/plans/2026-11-15-外部分享接收设计.md`
+
+---
+
+## 五、时间线
+
+| 日期 | 内容 |
+|---|---|
+| 2026-11-14 | 手机端 UX 优化(草稿隔离、状态可见性等)+ 外部方案评审(8 真 1 伪) |
+| 2026-11-15 上午 | Capacitor 打包、深链、本地通知;登录改云函数代理绕开多值 CORS |
+| 2026-11-15 下午 | 真机验证,修了 7 个问题(状态栏、返回键、白屏、通知三大坑) |
+| 2026-11-15 晚 | 第三轮修复(PDF worker、顶栏布局、tooltip、权限刷新),**待复验** |
+
+---
+
+## 六、踩过的坑(别重复踩)
+
+| 坑 | 教训 |
+|---|---|
+| **pdfjs worker 版本** | `public/pdf.worker.js` 是构建产物,`npm run build` **不会重新生成**。降级 pdfjs 后必须重跑 `node scripts/copy-pdf-worker.mjs`。已踩两次 |
+| Capacitor 的 `Style.Light/Dark` | 指**背景明暗**,不是图标颜色。写反会导致状态栏看不见 |
+| `StatusBar.getInfo().height` | 返回的**已经是 CSS 像素**,不要再除 devicePixelRatio |
+| `@capacitor/local-notifications` | 三个坑:`isExactNotification` 默认 true 跳闹钟页、渠道不创建静默丢通知、点击 PendingIntent 用 ACTION_VIEW 拉不起 App。**已改用自建插件** |
+| Android 8+ 通知渠道 | 指定 `channelId` 却不创建 = 通知被静默丢弃,且 `schedule()` 不报错 |
+| 物理返回键 | 不接管 = 弹层与路由一起后退;极端情况白屏 |
+| 云端多值 ACAO | 浏览器判非法直接拦截;Web 端被 nginx 代理掩盖了 |
+| `networkidle` | CDP 连 App 的 WebView 时用 `domcontentloaded`,Matrix 长轮询永不 idle |
+| **adb `input tap` 点击通知** | 通知卡片的可点区域要靠 `uiautomator dump` 拿 bounds,目测坐标会点空 |
+| **Angular 模板字符串里的反引号** | 组件 `styles: [\`...\`]` 内部写反引号会提前闭合,导致编译错误 |
+
+---
+
+## 七、本项目固定的测试规范(沿用)
+
+- 测试容器:**`agent-node-lj-test`**(Matrix `@zgdcmgnqcd:fmode.cn`),不是 `agent-node-app-lj-test-v1`
+- 容器结束信号:**`✅` reaction**,不是 `m.typing=false`
+- 真机调试:`adb forward` + CDP 连 WebView 读取内部状态,比截图可靠
+- 点击定位:用 `uiautomator dump` 拿精确 bounds,别靠目测坐标
+- 开发登录 token:`.claude/dev-login.json`(可直接注入 localStorage 免登录)
+
+---
+
+## 八、关键文件索引
+
+| 文件 | 作用 |
+|---|---|
+| `capacitor.config.ts` | Capacitor 配置(appId `cn.fmode.app` / webDir / origin) |
+| `src/app/core/safe-area.service.ts` | 状态栏安全区修正 |
+| `src/app/core/back-button.service.ts` | 物理返回键接管 |
+| `src/app/core/deep-link.service.ts` | `fmode://` 深链解析 |
+| `src/app/core/app-notification.service.ts` | 通知封装 + 权限状态 signal |
+| `src/app/mobile/components/permission-guide/` | 登录后权限引导弹层 |
+| `src/app/mobile/components/m-app-bar/m-app-bar.ts` | 顶栏(flex 三栏布局) |
+| `android/.../FmodeNotifyPlugin.java` | **自建通知插件**(替代 Capacitor 官方插件) |
+| `android/.../AppSettingsPlugin.java` | 跳系统设置页 |
+| `scripts/copy-pdf-worker.mjs` | pdf worker 生成(**带大小断言**) |
+| `cloud/functions/fmode-login-proxy.js` | 登录代理(绕多值 CORS),线上 id `zpus2KjtFH` |

+ 33 - 2
scripts/copy-pdf-worker.mjs

@@ -13,7 +13,7 @@
  * 1.3 MB 的构建产物,随 pdfjs 版本变化,没理由占仓库空间。
  * 由 `postinstall` 自动生成,克隆后 `npm install` 即得。
  */
-import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
 import { dirname, resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
 
@@ -30,7 +30,38 @@ if (!existsSync(src)) {
 
 mkdirSync(dirname(dest), { recursive: true });
 copyFileSync(src, dest);
-console.log('[copy-pdf-worker] 已生成 public/pdf.worker.js');
+
+/*
+ * 🔴 复制后**必须校验**(2026-11-15 真机事故后新增)
+ *
+ * ## 为什么
+ *
+ * 这个坑已经踩了**两次**:
+ *   ① 2026-11-13:降级 pdfjs 后没重跑本脚本 → 线上是 6.4 的 worker
+ *   ② 2026-11-15:同样的事又从真机 App 上冒出来 ——
+ *      主包 4.10.38,而 APK 里的 worker 是旧版 6.4.299(1,264,342 字节,
+ *      正确值应为 1,375,838),pdfjs 的 fake-worker fallback 失败后
+ *      `getDocument().promise` **既不 resolve 也不 reject**,
+ *      用户看到的是「一直转圈,永远加载不出来」。
+ *
+ * ## 本脚本只在 postinstall 跑
+ *
+ * 而 `npm run build` / `npx cap sync` 不会重跑它,只是把 public/ 下的
+ * **旧文件**一路拷进 APK。所以「文件存在但内容过期」是常态而非意外。
+ *
+ * 现在:复制完立刻比对大小,不一致就报错退出,把问题挡在构建期,
+ * 而不是等用户装完 App 发现打不开 PDF。
+ */
+const srcSize = statSync(src).size;
+const destSize = statSync(dest).size;
+if (srcSize !== destSize) {
+  console.error(
+    `[copy-pdf-worker] 🔴 复制结果不一致:src=${srcSize} dest=${destSize}\n` +
+      `  这会导致 pdfjs 主包与 worker 版本不匹配,PDF 永远转圈。`,
+  );
+  process.exit(1);
+}
+console.log(`[copy-pdf-worker] 已生成 public/pdf.worker.js(${destSize} 字节,已校验)`);
 
 /*
  * 同时生成 pdfjs 版本常量(2026-11-13)。

+ 22 - 8
src/app/components/preview/file-preview/file-preview.ts

@@ -21,6 +21,7 @@ import { FmodeApi } from '../../../core/api/fmode-api';
 import { isCloudUrl } from '../../../core/s3-link';
 import { downloadAs, needsBlobPreview, toBlobUrl } from '../../../core/attachment-fetch';
 import { copyLink, openInNewTab } from '../../../core/open-link';
+import { isNativeApp } from '../../../core/api/platform-proxy.service';
 
 /**
  * 预览呈现形态。
@@ -116,19 +117,19 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
             }
 
 <div class="fp-tools">
-              <ui-tooltip content="下载">
+              <ui-tooltip [disabled]="touchUi" content="下载">
                 <button type="button" class="fp-icon" aria-label="下载" (click)="download()">
                   <i class="fa-solid fa-download" aria-hidden="true"></i>
                 </button>
               </ui-tooltip>
               @if (kind() === 'pdf' || kind() === 'html') {
-                <ui-tooltip content="重新加载">
+                <ui-tooltip [disabled]="touchUi" content="重新加载">
                   <button type="button" class="fp-icon" aria-label="重新加载" (click)="reload()">
                     <i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
                   </button>
                 </ui-tooltip>
               }
-              <ui-tooltip content="在新标签页打开">
+              <ui-tooltip [disabled]="touchUi" content="在新标签页打开">
                 <button type="button" class="fp-icon" aria-label="在新标签页打开" (click)="openInTab()">
                   <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
                 </button>
@@ -138,7 +139,7 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
                 部分手机浏览器/容器内置浏览器会拦截或处理不好 window.open,
                 给一个"复制链接"的兜底出口 —— 用户粘到系统浏览器里就能打开。
               -->
-              <ui-tooltip [content]="copied() ? '已复制' : '复制链接'">
+              <ui-tooltip [disabled]="touchUi" [content]="copied() ? '已复制' : '复制链接'">
                 <button
                   type="button"
                   class="fp-icon"
@@ -152,7 +153,7 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
                   ></i>
                 </button>
               </ui-tooltip>
-              <ui-tooltip [content]="presentation() === 'full' ? '回到右侧' : '关闭预览'">
+              <ui-tooltip [disabled]="touchUi" [content]="presentation() === 'full' ? '回到右侧' : '关闭预览'">
                 <button
                   #closeBtn
                   type="button"
@@ -271,6 +272,7 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
                       class="fp-fill"
                       [url]="url()"
                       [zoom]="zoom()"
+                      [reloadTick]="reloadTick()"
                       [(page)]="pdfPage"
                       [(pageCount)]="pdfPageCount"
                     />
@@ -382,10 +384,10 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
                 }
               </span>
               <span class="fp-zoom">
-                <ui-tooltip content="缩小(Ctrl + 滚轮)">
+                <ui-tooltip [disabled]="touchUi" content="缩小(Ctrl + 滚轮)">
                   <button type="button" class="fp-icon" aria-label="缩小" (click)="zoomBy(-0.1)">−</button>
                 </ui-tooltip>
-                <ui-tooltip content="点击重置为 100%">
+                <ui-tooltip [disabled]="touchUi" content="点击重置为 100%">
                   <button
                     type="button"
                     class="fp-zoom-val"
@@ -395,7 +397,7 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
                     {{ (zoom() * 100).toFixed(0) }}%
                   </button>
                 </ui-tooltip>
-                <ui-tooltip content="放大(Ctrl + 滚轮)">
+                <ui-tooltip [disabled]="touchUi" content="放大(Ctrl + 滚轮)">
                   <button type="button" class="fp-icon" aria-label="放大" (click)="zoomBy(0.1)">+</button>
                 </ui-tooltip>
               </span>
@@ -997,6 +999,18 @@ export class FilePreviewComponent {
   /** 是否允许在 rail/full 间切换(右栏内联时打开) */
   readonly allowFullscreen = input(true);
 
+  /**
+   * 当前是否为触屏原生环境(2026-11-15)。
+   *
+   * 用于关闭 tooltip:提示是**鼠标悬停**语义的产物,
+   * 触屏点按钮时 WebView 会把焦点移到按钮上触发 `focusin`,
+   * 于是点一下图标就冒出一个气泡(真机实测,用户反馈「逻辑不对」)。
+   *
+   * 用 `isNativeApp()` 而不是 `detectUiMode()`:后者按视口宽度判断,
+   * 平板横屏会判成 desktop;而「有没有鼠标」这件事只有原生壳能确定。
+   */
+  protected readonly touchUi = isNativeApp();
+
   /** 请求关闭 */
   readonly closed = output<void>();
 

+ 43 - 1
src/app/components/preview/pdf-preview/pdf-preview.ts

@@ -397,7 +397,21 @@ export class PdfPreviewComponent implements AfterViewInit, OnDestroy {
         url,
         verbosity: pdfjs.VerbosityLevel?.ERRORS ?? 0,
       } as never);
-      const doc = (await task.promise) as unknown as PdfDoc;
+      /*
+       * 🔴 必须给 `task.promise` 加超时(2026-11-15 真机事故后新增)。
+       *
+       * pdfjs 在「主包与 worker 版本不匹配」时,会让 fake-worker fallback
+       * **既不 resolve 也不 reject** —— 这个 promise 永远挂着。
+       * 没有超时的话,用户看到的就是「一直转圈,永远加载不出来」,
+       * 而且**没有任何错误信息可以据此排查**(真机上就是这么表现了两天)。
+       *
+       * 超时后给一句能指导行动的话,而不是干等。
+       */
+      const doc = (await this.withTimeout(
+        task.promise,
+        PDF_LOAD_TIMEOUT_MS,
+        'PDF 加载超时。可能是文件过大,也可能是版本组件异常 —— 请下载后查看。',
+      )) as unknown as PdfDoc;
       this.doc = doc;
       this.state.set('ready');
       /*
@@ -413,6 +427,23 @@ export class PdfPreviewComponent implements AfterViewInit, OnDestroy {
     }
   }
 
+  /** 给 promise 套一个超时;超时抛 Error(带调用方给的文案) */
+  private withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
+    return new Promise<T>((resolve, reject) => {
+      const timer = setTimeout(() => reject(new Error(msg)), ms);
+      p.then(
+        (v) => {
+          clearTimeout(timer);
+          resolve(v);
+        },
+        (e) => {
+          clearTimeout(timer);
+          reject(e);
+        },
+      );
+    });
+  }
+
   /**
    * 建立页面占位 + 懒渲染观察器。
    *
@@ -644,6 +675,17 @@ function nextFrames(n: number): Promise<void> {
  * 现在依赖版本一变,URL 自动变化,不会再忘。
  */
 const WORKER_VERSION = PDFJS_VERSION;
+
+/**
+ * PDF 加载超时(2026-11-15)。
+ *
+ * 为什么需要:pdfjs 在 worker 版本不匹配、或网络卡住时,
+ * `getDocument().promise` 会**永远挂着**,既不 resolve 也不 reject。
+ * 没有超时就没有任何错误可展示,用户只能干等。
+ *
+ * 20 秒:移动网络下 10MB 的 PDF 也够用了,再长就是真的出问题了。
+ */
+const PDF_LOAD_TIMEOUT_MS = 20_000;
 function workerSrc(): string {
   return `${new URL('/pdf.worker.js', location.origin).toString()}?v=${WORKER_VERSION}`;
 }

+ 104 - 14
src/app/core/app-notification.service.ts

@@ -1,11 +1,15 @@
-import { Injectable, inject } from '@angular/core';
+import { Injectable, inject, signal } from '@angular/core';
 import { registerPlugin } from '@capacitor/core';
 import { isNativeApp } from './api/platform-proxy.service';
 import { DeepLinkService } from './deep-link.service';
+import { UiMToastService } from '../ui/mobile/components/toast/toast';
 
 /** 通知渠道 id(Android 8+ 必须先创建同名渠道,否则通知被静默丢弃) */
 const NOTIFY_CHANNEL_ID = 'fmode-default';
 
+/** 「已问过通知权限」的持久化标记(设备级,登出不清) */
+const PERM_PROMPTED_KEY = 'fmode.permPrompted';
+
 /** 自建原生通知插件(android/.../FmodeNotifyPlugin.java)的 TS 形状 */
 interface FmodeNotifyPlugin {
   show(o: { id?: number; title: string; body: string; deepLink?: string }): Promise<{ id: number }>;
@@ -65,11 +69,21 @@ export interface AppNotice {
 @Injectable({ providedIn: 'root' })
 export class AppNotificationService {
   private readonly deepLink = inject(DeepLinkService);
+  /** 轻提示(权限状态变化时给反馈) */
+  private readonly toast = inject(UiMToastService);
 
   /** 是否已尝试过初始化(避免每次都走一遍插件加载) */
   private initialized = false;
-  /** 用户是否授予了通知权限(`null` = 还没问过) */
-  private granted: boolean | null = null;
+  /**
+   * 通知权限状态(`null` = 还没查过)。
+   *
+   * 🔴 必须是 signal(2026-11-15 改)。原来是个普通字段且**永不清除**,
+   *    导致用户去系统设置开了权限、回到 App 还是 false —— UI 完全感知不到变化。
+   *    改成 signal 后「我的」页和引导层都能订阅,状态一变就跟着更新。
+   */
+  private readonly granted = signal<boolean | null>(null);
+  /** 供 UI 订阅的只读视图 */
+  readonly grantedState = this.granted.asReadonly();
   /** key → 通知 id 的映射(覆盖而非叠加) */
   private readonly idsByKey = new Map<string, number>();
   private nextId = 1000;
@@ -104,6 +118,25 @@ export class AppNotificationService {
         const target = this.deepLink.toRoute(pending.deepLink);
         if (target) void this.deepLink.navigate(target);
       }
+
+      /*
+       * 回到前台时刷新权限状态(2026-11-15)。
+       *
+       * 🔴 为什么必须有:用户在我们引导下跳去系统设置页开启通知,
+       *    再返回 App —— 没有任何事件告诉我们「他开了没」。
+       *    不刷新的话,界面永远显示「未开启」,用户会以为操作失败了。
+       *
+       * Android 上 Activity.onResume 会触发 `appStateChange{isActive:true}`,
+       * 系统设置页返回正好命中这个时机。
+       */
+      const { App } = await import('@capacitor/app');
+      await App.addListener('appStateChange', ({ isActive }) => {
+        if (!isActive) return;
+        void this.refreshGranted();
+      });
+
+      /* 启动时也先查一次,让 UI 一开始就有准确状态 */
+      void this.isGranted(true);
     } catch {
       /* 插件不可用:通知是增强能力,失败不能让 App 起不来 */
     }
@@ -145,19 +178,70 @@ export class AppNotificationService {
   }
 
   /** 当前是否已授权(不发请求,只查询;未知时返回 null) */
-  async isGranted(): Promise<boolean | null> {
+  /**
+   * 登录后是否该弹「开启通知」引导(2026-11-15)
+   *
+   * 规则:
+   *   · 非原生(浏览器)→ 不问
+   *   · 已授权 → 不问
+   *   · **问过且被拒绝** → 不再自动问(只在「我的」页保留入口)
+   *
+   * 🔴 「问过」要持久化:只放内存的话,用户每次冷启动都会被问一遍,
+   *    很快从「友好引导」变成「骚扰」——那是把人推去点拒绝的最快方式。
+   *    用一个 localStorage 标记,登出时不清(这是设备级偏好,不是账号级)。
+   */
+  shouldPromptPermission(): boolean {
+    if (!isNativeApp()) return false;
+    try {
+      if (localStorage.getItem(PERM_PROMPTED_KEY) === '1') return false;
+    } catch {
+      /* 隐私模式读不到 → 当作没问过 */
+    }
+    return true;
+  }
+
+  /** 标记「已问过」,之后不再自动弹 */
+  markPrompted(): void {
+    try {
+      localStorage.setItem(PERM_PROMPTED_KEY, '1');
+    } catch {
+      /* 忽略 */
+    }
+  }
+
+  /**
+   * 查询权限状态。
+   *
+   * @param force 为 true 时**绕过缓存**重新问系统(2026-11-15 新增)。
+   *   用户从系统设置回来必须用 force,否则永远读到旧值。
+   */
+  async isGranted(force = false): Promise<boolean | null> {
     if (!isNativeApp()) return null;
-    if (this.granted !== null) return this.granted;
+    if (!force && this.granted() !== null) return this.granted();
     try {
       const { LocalNotifications } = await import('@capacitor/local-notifications');
       const cur = await LocalNotifications.checkPermissions();
-      this.granted = cur.display === 'granted';
-      return this.granted;
+      this.granted.set(cur.display === 'granted');
+      return this.granted();
     } catch {
       return null;
     }
   }
 
+  /**
+   * 强制刷新权限状态(用户从系统设置返回时调用)。
+   *
+   * @returns 刷新后的状态;若**从 false 变成 true**,顺便弹一条成功提示。
+   */
+  async refreshGranted(): Promise<boolean | null> {
+    const before = this.granted();
+    const now = await this.isGranted(true);
+    if (before === false && now === true) {
+      this.toast.success('通知权限已开启,任务完成后会提醒你');
+    }
+    return now;
+  }
+
   /**
    * 申请通知权限。
    *
@@ -165,24 +249,30 @@ export class AppNotificationService {
    */
   async request(): Promise<boolean> {
     if (!isNativeApp()) return false;
-    if (this.granted !== null) return this.granted;
+    /*
+     * 🔴 只在**已授权**时短路(2026-11-15 修正)。
+     *    原来写的是 `if (this.granted !== null) return this.granted;` ——
+     *    只要 `isGranted()` 先跑过并写入了 false,`request()` 就永远直接返回 false,
+     *    **连系统弹窗都不会弹**。这是个独立的 bug,会把用户彻底挡在门外。
+     */
+    if (this.granted() === true) return true;
     try {
       const { LocalNotifications } = await import('@capacitor/local-notifications');
       const cur = await LocalNotifications.checkPermissions();
       if (cur.display === 'granted') {
-        this.granted = true;
+        this.granted.set(true);
         return true;
       }
       /*
        * 已被拒绝过一次 → 系统不再弹窗,直接返回 denied。
-       * 此时 `this.granted` 会记成 false,上层可用 `isGranted()` 区分
-       * 「还没问过」与「问过被拒」,从而决定是否给引导提示。
+       * 上层可用 `isGranted()` 区分「还没问过」与「问过被拒」,
+       * 从而决定是弹窗还是引导去设置页。
        */
       const res = await LocalNotifications.requestPermissions();
-      this.granted = res.display === 'granted';
-      return this.granted;
+      this.granted.set(res.display === 'granted');
+      return this.granted() === true;
     } catch {
-      this.granted = false;
+      this.granted.set(false);
       return false;
     }
   }

+ 34 - 31
src/app/mobile/components/m-app-bar/m-app-bar.ts

@@ -28,7 +28,7 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
         <span class="m-fab-spacer" aria-hidden="true"></span>
       }
 
-      <div class="m-title" [style.right]="titleRight()">
+      <div class="m-title">
         <span class="m-title-main">{{ title() }}</span>
         @if (subtitle()) {
           <small>{{ subtitle() }}</small>
@@ -76,6 +76,9 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
         display: grid;
         place-items: center;
         flex: none;
+        /* 兜底:即便将来标题层又出了什么溢出,按钮也永远在上层 */
+        position: relative;
+        z-index: 1;
         background: var(--surface);
         box-shadow: var(--shadow-soft);
         color: var(--ink);
@@ -93,30 +96,29 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
         flex: none;
       }
       /*
-       * 标题层铺满整条顶栏并居中;左右按钮盖在其上。
+       * 标题改用 flex 三栏:[左 44] [中间 flex:1] [右 auto](2026-11-15 真机事故后重构)
+       *
+       * 🔴 原来用 position: absolute 铺满整条顶栏来做「绝对居中」,代价是:
+       *    左右按钮是普通 flex 子项、没有 z-index,
+       *    而绝对定位层**默认绘制在普通流之后** → 标题任何溢出都会画在按钮之上。
+       *
+       *    真机表现:第一个会话标题「给我一个 HTML …」+ 副标题「lj-test · 在线」,
+       *    副标题没有 max-width 撑满整个标题层,左边界直抵返回按钮,把返回键盖住了。
+       *    max-width: 70% 挡不住,因为 70% 是相对**标题层**算的,不是相对顶栏。
        *
-       * 🔴 2026-11-07 修复重叠:原来 top 为安全区、bottom 为 0,直接铺满,
-       *    但**没有约束子项高度** —— 标题 + 副标题两行同时存在时,
-       *    文字会溢出这条绝对定位层、压到下方的消息区上(用户截图实测)。
-       *    现在给标题层显式高度(与按钮同等的 44px 视觉行),并裁掉溢出。
+       * 三栏布局天然解决:中间栏 flex:1 + min-width:0,只会占用「左按钮之后」的空间,
+       * 标题再长也只会在自己栏内省略,物理上不可能越界到按钮上。
+       * 左右两栏等宽时标题依然居中;右侧按钮多时标题区自动收窄 —— 正是想要的行为。
        */
       .m-title {
-        position: absolute;
-        left: var(--touch-min);
-        /*
-         * right 由模板绑定 titleRight() 计算(按钮数 × 44px + 余量)。
-         * 🔴 2026-11-09:原来写死一行 44px,会话页有 2 个按钮时标题会盖住第二个。
-         * 这里保留默认值,实际值以 [style.right] 为准。
-         */
-        right: var(--touch-min);
-        top: var(--mob-safe-top);
+        flex: 1 1 auto;
+        min-width: 0;
         height: calc(var(--mob-navbar-h) - var(--sp-2));
         display: flex;
         flex-direction: column;
         align-items: center;
         justify-content: center;
         gap: 1px;
-        min-width: 0;
         overflow: hidden;
         pointer-events: none;
         text-align: center;
@@ -128,19 +130,25 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
       .m-title-main {
         display: block;
         /*
-         * 70%:标题最多占顶栏宽度的七成,超出直接省略号。
-         *
-         * 🔴 2026-11-09 用户实测指正:原先写 100% 会顶满整个标题层,
-         *    配合右侧 2 个按钮时视觉上更挤。70% 留出呼吸空间,
-         *    长标题也不会贴到按钮边上。
+         * 改为 100%(2026-11-15):三栏布局后,标题列本身已经被左右按钮夹住了,
+         * 不会越界。再留 70% 只会让长标题更早被截断,白白浪费空间。
+         * 「留呼吸空间」这个诉求由三栏之间的 gap(--sp-3 = 12px)承担。
          */
-        max-width: 70%;
+        max-width: 100%;
         overflow: hidden;
         text-overflow: ellipsis;
         white-space: nowrap;
       }
       .m-title small {
         display: block;
+        /*
+         * 🔴 必须给 max-width(2026-11-15 真机事故)。
+         *    原来只有 .m-title-main 有宽度约束,副标题裸奔 ——
+         *    「lj-test · 在线」这类副标题会撑满整个标题层,
+         *    在绝对定位时代直接盖住返回按钮。
+         *    三栏布局已经兜住了越界,这里再补一道,让省略号位置可控。
+         */
+        max-width: 100%;
         font-size: var(--fs-11);
         font-weight: 400;
         color: var(--muted);
@@ -167,19 +175,14 @@ export class MAppBarComponent {
   /** 右侧是否已有内容(父组件通过 `[barRight]` 投影);false 时渲染占位保持标题居中 */
   readonly hasRightContent = input(true);
   /**
-   * 右侧按钮数量(2026-11-09)。
-   *
-   * 标题层是绝对定位并铺满的,必须按实际按钮数预留宽度,
-   * 否则长标题会盖住按钮(用户实测:会话页 2 个按钮被盖住第二个)。
+   * @deprecated 2026-11-15 起标题改为 flex 三栏布局,右侧栏宽度由实际投影的按钮
+   * 自动撑开,不再需要手工计数。保留此 input 仅为不破坏现有调用方(聊天页仍传 2),
+   * 传与不传都不影响布局。新代码不要再传。
    */
   readonly rightCount = input(1);
 
   /**
-   * 标题层的 `right` 值 = 按钮数 × 44px + 16px 余量。
-   *
-   * 🔴 不用 CSS 变量传递:实测 Angular 的 `[style.--x]` 对自定义属性不生效
-   *    (computed 里读到空字符串),所以直接算出像素值绑到 `right`。
-   * 余量取 16:按钮之间还有 gap,实测 8px 会压到第一个按钮左边缘。
+   * @deprecated 同上,三栏布局后已不再使用(渲染中无引用)。
    */
   readonly titleRight = computed(() => `${this.rightCount() * 44 + 28}px`);
 

+ 214 - 0
src/app/mobile/components/permission-guide/permission-guide.ts

@@ -0,0 +1,214 @@
+import { ChangeDetectionStrategy, Component, inject, input, output, signal } from '@angular/core';
+import { AppNotificationService } from '../../../core/app-notification.service';
+
+/**
+ * 权限引导弹层(2026-11-15)
+ *
+ * ## 为什么需要
+ *
+ * 用户在「我的」页里找通知开关这件事,很多人根本不会做。
+ * 更常见的情况是:用了一周才想起「这个 App 怎么从来不提醒我」,
+ * 而那时通知权限早就被某个系统弹窗顺手拒掉了。
+ *
+ * 所以在**登录成功后**主动问一次,并且**说清楚要它干什么** ——
+ * 这比一个沉默的系统权限框有效得多。
+ *
+ * ## 适用范围
+ *
+ * 🔴 **仅移动端**。桌面浏览器没有这些系统权限,也不需要。
+ *    调用方负责判断(`isNativeApp()`),本组件不做环境判断。
+ *
+ * ## 交互
+ *
+ * 只问「通知」这一项。不一次性罗列存储/相机/定位:
+ * 我们目前真的只用到通知,多要一个权限就是在透支用户信任。
+ * 将来真要加,往 `ITEMS` 里加一项即可,结构已经支持。
+ */
+@Component({
+  selector: 'app-permission-guide',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    @if (open()) {
+      <div class="pg-root" role="dialog" aria-modal="true" aria-label="开启通知">
+        <div class="pg-mask" (click)="dismiss.emit()"></div>
+        <section class="pg-card">
+          <div class="pg-icon"><i class="fa-solid fa-bell" aria-hidden="true"></i></div>
+          <h2 class="pg-title">开启通知提醒</h2>
+          <p class="pg-desc">
+            任务跑完、容器有新回复时,我们会发一条通知告诉你。
+            不开也能正常用,只是要你自己回来查看。
+          </p>
+
+          <ul class="pg-list">
+            <li>
+              <i class="fa-solid fa-circle-check" aria-hidden="true"></i>
+              <span><b>任务完成</b>:容器处理完你的请求时提醒</span>
+            </li>
+            <li>
+              <i class="fa-solid fa-circle-check" aria-hidden="true"></i>
+              <span><b>新回复</b>:重要消息到了及时知道</span>
+            </li>
+          </ul>
+
+          <p class="pg-note">
+            系统要求你确认一次授权,之后可在「我的 → 通知」里随时关闭。
+          </p>
+
+          <div class="pg-actions">
+            <button type="button" class="pg-btn" (click)="dismiss.emit()">以后再说</button>
+            <button type="button" class="pg-btn pg-btn--primary" [disabled]="busy()" (click)="allow()">
+              {{ busy() ? '正在开启…' : '开启通知' }}
+            </button>
+          </div>
+        </section>
+      </div>
+    }
+  `,
+  styles: [
+    `
+      .pg-root {
+        position: fixed;
+        inset: 0;
+        z-index: var(--z-modal);
+        display: grid;
+        place-items: center;
+        padding: var(--sp-6);
+      }
+      .pg-mask {
+        position: absolute;
+        inset: 0;
+        background: var(--overlay);
+      }
+      .pg-card {
+        position: relative;
+        width: 100%;
+        max-width: 340px;
+        display: flex;
+        flex-direction: column;
+        gap: var(--sp-3);
+        padding: var(--sp-6) var(--sp-5) var(--sp-5);
+        border-radius: var(--r-lg);
+        background: var(--surface);
+        box-shadow: var(--shadow-overlay);
+        text-align: center;
+      }
+      .pg-icon {
+        width: 56px;
+        height: 56px;
+        margin: 0 auto;
+        display: grid;
+        place-items: center;
+        border-radius: 50%;
+        background: var(--primary-subtle);
+        color: var(--primary);
+        font-size: var(--fs-22);
+      }
+      .pg-title {
+        margin: 0;
+        font-size: var(--fs-17);
+        color: var(--ink);
+      }
+      .pg-desc {
+        margin: 0;
+        font-size: var(--fs-13);
+        line-height: 1.6;
+        color: var(--muted);
+        text-align: left;
+      }
+      .pg-list {
+        margin: 0;
+        padding: var(--sp-3);
+        list-style: none;
+        display: flex;
+        flex-direction: column;
+        gap: var(--sp-2);
+        border-radius: var(--r-md);
+        background: var(--surface-2);
+        text-align: left;
+      }
+      .pg-list li {
+        display: flex;
+        gap: var(--sp-2);
+        align-items: flex-start;
+        font-size: var(--fs-13);
+        line-height: 1.5;
+        color: var(--ink-2);
+      }
+      .pg-list i {
+        color: var(--success);
+        font-size: var(--fs-12);
+        margin-top: 3px;
+        flex: none;
+      }
+      .pg-note {
+        margin: 0;
+        font-size: var(--fs-12);
+        line-height: 1.5;
+        color: var(--faint);
+        text-align: left;
+      }
+      .pg-actions {
+        display: flex;
+        gap: var(--sp-2);
+        margin-top: var(--sp-1);
+      }
+      .pg-btn {
+        flex: 1;
+        min-height: var(--touch-min);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-md);
+        background: var(--surface);
+        color: var(--ink);
+        font: inherit;
+        font-size: var(--fs-15);
+        cursor: pointer;
+      }
+      .pg-btn--primary {
+        border-color: var(--primary);
+        background: var(--primary);
+        color: var(--primary-content);
+      }
+      .pg-btn:disabled {
+        opacity: 0.6;
+        cursor: not-allowed;
+      }
+    `,
+  ],
+})
+export class PermissionGuideComponent {
+  private readonly notifySvc = inject(AppNotificationService);
+
+  readonly open = input(false);
+  /** 用户关闭(拒绝或开启后) */
+  readonly dismiss = output<void>();
+
+  protected readonly busy = signal(false);
+
+  /** 发起系统授权;被拒时引导到设置页 */
+  protected async allow(): Promise<void> {
+    if (this.busy()) return;
+    this.busy.set(true);
+    try {
+      const ok = await this.notifySvc.request();
+      if (ok) return;
+
+      /*
+       * 系统不再弹窗(用户拒绝过一次)→ 带用户去系统设置页。
+       * 跳到本 App 应用信息页,第一屏就有「通知管理」。
+       *
+       * 🔴 跳转后**不立刻 dismiss**(2026-11-15 修正):
+       *    原来 `finally` 里无条件关掉弹层,用户被送去设置页、
+       *    开完权限回来,弹层早没了,也**没有任何地方告诉他成功了没有**。
+       *
+       *    现在:发起跳转后就关掉(避免回来时压着一个旧弹层),
+       *    由 `AppNotificationService` 的 `appStateChange` 在回到前台时
+       *    刷新状态并 toast 提示「通知权限已开启」。反馈落在 toast 上,
+       *    不依赖这个弹层还活着。
+       */
+      await this.notifySvc.openNotificationSettings();
+    } finally {
+      this.busy.set(false);
+      this.dismiss.emit();
+    }
+  }
+}

+ 8 - 3
src/app/mobile/pages/mine/mine.ts

@@ -493,14 +493,19 @@ export class MbMinePage {
 
   private readonly notify = inject(AppNotificationService);
   /** 通知权限状态:null = 未知/查询中,true = 已开,false = 未开 */
-  protected readonly notifyGranted = signal<boolean | null>(null);
+  /**
+   * 通知权限状态:直接订阅 service 的 signal(2026-11-15)。
+   * 原来的本地 signal 只在构造函数查一次,从系统设置返回后不会更新。
+   */
+  protected readonly notifyGranted = computed(() => this.notify.grantedState());
 
   /** 是否原生壳(浏览器里不显示「通知」入口 —— 浏览器没有这个系统权限) */
   protected readonly isNative = signal(isNativeApp());
 
   /** 进页面时查一次通知权限状态(构造函数里调用,不另开第二个 constructor) */
   private loadNotificationState(): void {
-    void this.notify.isGranted().then((g) => this.notifyGranted.set(g));
+    /* 进页面时强制刷新一次:用户可能刚从系统设置回来 */
+    void this.notify.refreshGranted();
   }
 
   protected notifyStateText(): string {
@@ -520,7 +525,7 @@ export class MbMinePage {
       return;
     }
     const ok = await this.notify.request();
-    this.notifyGranted.set(ok);
+    /* 状态由 service 的 signal 驱动,这里不需要手动写回 */
     if (!ok) {
       /* 系统不再弹窗了 → 只能引导用户自己去设置里开 */
       await this.notify.openNotificationSettings();

+ 40 - 1
src/app/mobile/shell/mobile-shell.ts

@@ -8,6 +8,8 @@ import { DeviceService } from '../../core/device.service';
 import { UnreadService } from '../../core/unread.service';
 import { SessionService } from '../../core/session.service';
 import { FmodeApi } from '../../core/api/fmode-api';
+import { AppNotificationService } from '../../core/app-notification.service';
+import { PermissionGuideComponent } from '../components/permission-guide/permission-guide';
 import { PromptModalComponent } from '../../components/domain/prompt-modal/prompt-modal';
 
 interface TabDef {
@@ -30,12 +32,20 @@ interface TabDef {
 @Component({
   selector: 'app-mobile-shell',
   changeDetection: ChangeDetectionStrategy.OnPush,
-  imports: [RouterOutlet, PromptModalComponent],
+  imports: [RouterOutlet, PromptModalComponent, PermissionGuideComponent],
   template: `
     <main class="m-main" id="main">
       <router-outlet />
     </main>
 
+    <!--
+      登录后一次性通知权限引导(2026-11-15,仅移动端)。
+      🔴 挂在 shell 而不是登录页:登录会从多条路径回到这里
+         (密码 / 验证码 / Token / redirect 回跳),挂在 shell 才能「只问一次」且不漏。
+      是否该弹由 AppNotificationService 判断(问过就不再自动弹)。
+    -->
+    <app-permission-guide [open]="permGuideOpen()" (dismiss)="onPermDismiss()" />
+
     <nav class="m-tabbar" aria-label="主导航">
       @for (tab of tabs; track tab.path; let i = $index) {
         <button
@@ -248,6 +258,33 @@ interface TabDef {
   ],
 })
 export class MobileShellComponent implements OnDestroy {
+  /** 登录后的通知权限引导(2026-11-15,仅移动端) */
+  private readonly notifySvc = inject(AppNotificationService);
+  protected readonly permGuideOpen = signal(false);
+
+  /**
+   * 是否需要弹通知权限引导。
+   *
+   * 延后 800ms:刚登录进来要加载会话列表、建 Matrix 连接,
+   * 弹层立刻盖上来会显得很急躁,也让首屏看起来像卡住了。
+   */
+  private async maybeShowPermGuide(): Promise<void> {
+    if (!this.notifySvc.shouldPromptPermission()) return;
+    /* 已授权的就没必要问 —— 直接标记,不再打扰 */
+    if ((await this.notifySvc.isGranted()) === true) {
+      this.notifySvc.markPrompted();
+      return;
+    }
+    await new Promise((r) => setTimeout(r, 800));
+    this.permGuideOpen.set(true);
+  }
+
+  /** 用户关掉引导(同意或拒绝都算),记下「问过了」 */
+  protected onPermDismiss(): void {
+    this.permGuideOpen.set(false);
+    this.notifySvc.markPrompted();
+  }
+
   private readonly live = inject(LiveService);
   private readonly matrix = inject(MatrixSessionService);
   private readonly palette = inject(PaletteService);
@@ -279,6 +316,8 @@ export class MobileShellComponent implements OnDestroy {
     // 🔴 Matrix 会话级连接(常驻,未读计数依赖它)
     this.matrix.bootstrap();
     this.palette.init();
+    /* 登录后一次性通知权限引导(2026-11-15) */
+    void this.maybeShowPermGuide();
     /*
      * 启动时刷新身份(2026-11-05):管理员角色可能在后台被调整过,
      * 只登录一次、之后一直用本地副本会导致权限变化不生效。

+ 21 - 0
src/app/ui/components/tooltip/tooltip.ts

@@ -27,6 +27,25 @@ export class UiTooltip {
   readonly delay = input(200);
   /** 空间不足时自动翻转方向 */
   readonly autoFlip = input(true, { transform: booleanAttribute });
+  /**
+   * 禁用提示(2026-11-15 新增)
+   *
+   * ## 为什么需要
+   *
+   * tooltip 是**鼠标悬停**语义的产物。触屏上没有 hover,
+   * 但 WebView 在点击按钮时会把焦点移到按钮上 → 触发模板里的 `focusin` →
+   * 200ms 后弹出一条提示,而且会一直显示到焦点移走。
+   *
+   * 真机表现:文件预览工具栏点「下载」,上方冒出一个「下载」气泡,
+   * 用户觉得莫名其妙 —— 他已经看到图标了,不需要再被念一遍。
+   *
+   * ## 为什么加在底座而不是在业务侧包一层 @if
+   *
+   * 包 @if 要复制 8 处按钮结构,容易漏、难维护;
+   * 而 `disabled` 是**向后兼容**的最小扩展:不传 = 行为完全不变,
+   * 桌面端零影响。底座在 `rules/18` 里明确「复制后归项目所有、可按需修改」。
+   */
+  readonly disabled = input(false, { transform: booleanAttribute });
 
   protected readonly visible = signal(false);
   /** 面板 fixed 定位坐标(null=未定位,面板先隐藏避免闪位) */
@@ -71,6 +90,8 @@ export class UiTooltip {
   }
 
   show(): void {
+    /* 被禁用时直接不显示(触屏环境由调用方传 disabled) */
+    if (this.disabled()) return;
     clearTimeout(this.timer);
     // 自动翻转:方向空间不足时反向,保证气泡不被视口边缘裁切
     let eff = this.position();