Prechádzať zdrojové kódy

fix: PDF 卡加载、外链新标签无效、历史记录加载不出

## PDF 一直卡在「正在加载」
根因:worker 缓存键 `WORKER_VERSION = '20261021-1'` 是手写常量,
pdfjs 从 6.4.299 降到 4.10.38 后没更新 → 浏览器命中缓存的旧 worker
→ 主包 4.10 / worker 6.4 协议不匹配,永远加载不完。
修法:由 scripts/copy-pdf-worker.mjs 自动生成版本常量
(pdfjs-version.generated.ts),依赖一变缓存键自动变。

## 所有预览「在新标签打开」点了没反应
根因:`window.open(u,'_blank','noopener,noreferrer')` 在 iOS Safari 返回 null,
原代码据此判定"弹窗被拦"→ 走 download(),用户看到的就是没反应。
修法:不带特性字符串同步开窗,再手动把 opener 置空。

## 外链打开是 about:blank
根因:所有链接都走"先开 about:blank → fetch → 跳转"的 blob 中转,
那是 Matrix 附件(强制 attachment 头)才需要的。
公网直链(s3.fmode.cn)改为直接 window.open。
HTML 预览同理:公网直链直接给 iframe,不再 fetch 成 blob
(移动端白屏很久,且 Safari 对 blob + sandbox 处理与 Chrome 不同)。

## 历史记录加载不出来
- backfillSessionTimeline 网络失败时原先直接放弃 → 改为重试一次
- unloaded 空态补「重新加载」按钮(移动端 + 桌面端)
- 文案改为「消息暂时没加载出来」,不再误报「没有可加载的消息」

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 3 hodín pred
rodič
commit
c763ef9267

+ 26 - 1
scripts/copy-pdf-worker.mjs

@@ -13,7 +13,7 @@
  * 1.3 MB 的构建产物,随 pdfjs 版本变化,没理由占仓库空间。
  * 由 `postinstall` 自动生成,克隆后 `npm install` 即得。
  */
-import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
+import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
 import { dirname, resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
 
@@ -31,3 +31,28 @@ if (!existsSync(src)) {
 mkdirSync(dirname(dest), { recursive: true });
 copyFileSync(src, dest);
 console.log('[copy-pdf-worker] 已生成 public/pdf.worker.js');
+
+/*
+ * 同时生成 pdfjs 版本常量(2026-11-13)。
+ *
+ * ## 为什么需要
+ *
+ * `pdf-preview.ts` 用版本号做 worker 的缓存键。之前是手写常量,
+ * pdfjs 从 6.4 降到 4.10 时忘了改 → 浏览器命中缓存的旧 worker
+ * → 主包与 worker 版本不匹配 → PDF 永远卡在「正在加载」。
+ *
+ * 现在由本脚本把 `node_modules/pdfjs-dist/package.json` 的 version 写进源码,
+ * 依赖一变缓存键就变,不可能再忘。
+ */
+const versionFile = resolve(root, 'src/app/components/preview/pdf-preview/pdfjs-version.generated.ts');
+const pkgPath = resolve(root, 'node_modules/pdfjs-dist/package.json');
+if (existsSync(pkgPath)) {
+  const { version } = JSON.parse(readFileSync(pkgPath, 'utf8'));
+  writeFileSync(
+    versionFile,
+    `/* 由 scripts/copy-pdf-worker.mjs 自动生成,请勿手改。 */\n` +
+      `export const PDFJS_VERSION = '${version}';\n`,
+    'utf8',
+  );
+  console.log(`[copy-pdf-worker] 已生成 pdfjs 版本常量:${version}`);
+}

+ 83 - 10
src/app/components/preview/file-preview/file-preview.ts

@@ -1251,21 +1251,54 @@ export class FilePreviewComponent {
   /**
    * 在新标签页打开。
    *
-   * 修过的坑(2026-11-01):
-   * ① `window.open(u,'_blank','noopener,noreferrer')` 带 noopener 时返回 null,
-   *    被误判成"弹窗被拦"→ 退化为下载。
-   * ② 附件地址是 Matrix 的 `/_matrix/media/v3/download/...`,服务端**强制**
-   *    `Content-Disposition: attachment`(curl 实测),所以无论怎么打开都是下载。
-   *    页面内直接 fetch 该地址会被 CORS 拦(不带 token 时)。
+   * ## 两种地址,走两条路(2026-11-13 修正)
    *
-   * 现在:先同步开窗(保住用户手势),再用**带 access_token** 的地址 fetch 成 blob
-   *    (该接口返回 `access-control-allow-origin: *`,实测可跨域读取),
-   *    把 blob 地址塞进新标签页 —— blob 由浏览器内联渲染,绕过 attachment 头。
-   *    fetch 失败再退回带 token 的原地址(至少能下载到正确文件)。
+   * ① **公网直链**(`s3.fmode.cn` / `file-cloud.fmode.cn` 等):
+   *    直接 `window.open(url)` 即可。原实现也走 blob 中转,
+   *    结果手机上长时间停在 `about:blank`(fetch 整个文件太慢),
+   *    用户看到就是"空页面"(真机实测反馈)。
+   *
+   * ② **Matrix 附件**(`/_matrix/media/...`):服务端强制
+   *    `Content-Disposition: attachment`,直接开只会下载、不会内联显示。
+   *    才需要「先同步开窗保住用户手势 → fetch 成 blob → 塞进新标签页」。
+   *
+   * 判断依据:是否与 Matrix homeserver 同源。
    */
   async openInTab(): Promise<void> {
     const base = this.safeUrl();
     if (!base) return;
+
+    /*
+     * ① 公网直链:直接打开,不做任何中转。
+     *
+     * 🔴 不要用 `window.open(u, '_blank', 'noopener,noreferrer')`(2026-11-13 真机实测)。
+     *
+     *    带 `noopener` 特性字符串时,**iOS Safari 会返回 null**
+     *    (规范允许实现这么干),原代码据此判断"弹窗被拦"→ 走 `download()`,
+     *    用户看到的就是「点了新标签没反应,文件也没下下来」。
+     *
+     *    正确做法:先**不带特性**同步开窗(保住用户手势、拿到 window 引用),
+     *    再手动把 `opener` 置空 —— 效果等同 noopener,且不会被误判。
+     */
+    if (!isMatrixMediaUrl(base)) {
+      const direct = window.open(base, '_blank');
+      if (direct) {
+        try {
+          direct.opener = null;
+        } catch {
+          /* 跨域时不可写,仍有 href 层面的保护 */
+        }
+        return;
+      }
+      /*
+       * 真的被拦了(比如用户开了弹窗阻止):退回本地下载,
+       * 不要留一个 about:blank 空页。
+       */
+      void this.download();
+      return;
+    }
+
+    /* ② Matrix 附件:必须走 blob 中转(见下方原有注释) */
     const authed = this.mx.withAccessToken(base);
     const w = window.open('about:blank', '_blank');
     try {
@@ -1357,6 +1390,19 @@ export class FilePreviewComponent {
   private async prepareBlob(url: string): Promise<void> {
     this.releaseBlob();
     if (!needsBlobPreview(this.name() || url)) return;
+
+    /*
+     * 🔴 公网直链直接给 iframe,不做 blob 中转(2026-11-13 真机实测修正)。
+     *
+     * 原实现对**所有** html 都先 fetch 成 blob。问题是:
+     *  · 移动端要先把整个文件下完才渲染 → 白屏很久(用户以为打不开);
+     *  · Safari 对 `blob:` + iframe sandbox 的处理与 Chrome 不同,
+     *    真机上会出现「预览空白」而电脑上正常;
+     *  · 大文件还会二次占用内存(下载一次 + blob 再读一次)。
+     *
+     * 只有需要带 token 才能读的地址(Matrix 媒体接口)才必须走 blob。
+     */
+    if (!needsAuthToRead(url)) return;
     try {
       const authed = this.mx.withAccessToken(url);
       const r = await toBlobUrl(authed, 'text/html');
@@ -1494,3 +1540,30 @@ function findScroller(root: HTMLElement): HTMLElement {  const candidates: HTMLE
   // 都不可滚(或没有内部滚动容器)→ 用外层
   return root;
 }
+
+/**
+ * 该地址是不是 Matrix 媒体接口(需要 token + blob 中转才能内联打开)。
+ *
+ * Matrix 的 `/_matrix/media/...` 服务端强制 `Content-Disposition: attachment`,
+ * 直接开只会下载;而 s3/CDN 的公网直链可以直接开。
+ *
+ * 判定:同源且路径含 `/_matrix/media`。
+ */
+function isMatrixMediaUrl(url: string): boolean {
+  try {
+    const u = new URL(url, location.origin);
+    return u.origin === location.origin && u.pathname.includes('/_matrix/media');
+  } catch {
+    return false;
+  }
+}
+
+/**
+ * 该地址是否**必须带 token** 才能读取(只有这类才需要 blob 中转)。
+ *
+ * 判断依据:Matrix 媒体接口(同源 `/_matrix/media`)需要 access_token,
+ * 而 s3/CDN 的公网直链是公开可读的 —— 直接给 iframe 就能渲染。
+ */
+function needsAuthToRead(url: string): boolean {
+  return isMatrixMediaUrl(url);
+}

+ 16 - 2
src/app/components/preview/pdf-preview/pdf-preview.ts

@@ -12,6 +12,8 @@ import {
   signal,
   viewChild,
 } from '@angular/core';
+/* pdfjs 版本号:由 scripts/copy-pdf-worker.mjs 自动生成,勿手改 */
+import { PDFJS_VERSION } from './pdfjs-version.generated';
 
 /**
  * PDF 预览 —— 逐页 canvas 渲染(**懒渲染**)。
@@ -604,8 +606,20 @@ function nextFrames(n: number): Promise<void> {
   });
 }
 
-/** worker 地址带缓存版本(企微等内置浏览器会长期复用旧 worker) */
-const WORKER_VERSION = '20261021-1';
+/**
+ * worker 地址带缓存版本(企微等内置浏览器会长期复用旧 worker)。
+ *
+ * 🔴 **版本号由构建脚本自动生成**,与 pdfjs-dist 的实际版本保持一致
+ *    (`scripts/copy-pdf-worker.mjs` 写 `pdfjs-version.generated.ts`)。
+ *
+ * 事故经过(2026-11-13 真机实测):pdfjs 从 6.4.299 降到 4.10.38 后,
+ * 这里仍是手写的旧值 `20261021-1` → 浏览器/CDN 命中缓存的**旧 worker**
+ * → 主包是 4.10 的 pdfjs、worker 是 6.4 的,协议不匹配
+ * → PDF 一直卡在「正在加载」,永远不完成。
+ *
+ * 现在依赖版本一变,URL 自动变化,不会再忘。
+ */
+const WORKER_VERSION = PDFJS_VERSION;
 function workerSrc(): string {
   return `${new URL('/pdf.worker.js', location.origin).toString()}?v=${WORKER_VERSION}`;
 }

+ 2 - 0
src/app/components/preview/pdf-preview/pdfjs-version.generated.ts

@@ -0,0 +1,2 @@
+/* 由 scripts/copy-pdf-worker.mjs 自动生成,请勿手改。 */
+export const PDFJS_VERSION = '4.10.38';

+ 17 - 2
src/app/core/chat.service.ts

@@ -2471,8 +2471,23 @@ export class ChatService {
         try {
           ok = await this.mx.paginateBack(rid, PAGE);
         } catch {
-          /* 网络抖动:不阻断打开会话(**不能**据此认为翻到底了) */
-          break;
+          /*
+           * 🔴 网络抖动**不能直接放弃**(2026-11-13 真机实测)。
+           *
+           * 原实现 catch 后直接 break → `hit()` 为 false →
+           * 界面显示「这个会话没有可加载的消息」。
+           * 移动网络下一次请求失败,整条历史就"消失"了 —— 用户看到的就是
+           * 「预览完返回 / 刷新后历史不见了」。
+           *
+           * 现在:先重试一次(1 秒后);仍失败才退出循环,
+           * 并且**不置 `_reachedRoomStart`**(不能把网络失败当成"翻到底了")。
+           */
+          await new Promise((r) => setTimeout(r, 1000));
+          try {
+            ok = await this.mx.paginateBack(rid, PAGE);
+          } catch {
+            break; // 两次都失败:退出,保持「未翻到底」语义
+          }
         }
         /*
          * 🔴 SDK 语义:`paginateEventTimeline` 返回 **false = 已到 timeline 尽头**

+ 6 - 2
src/app/desktop/pages/chat/chat.ts

@@ -441,10 +441,14 @@ interface ConversationRow {
                 -->
                 <div class="no-session">
                   <i class="fa-solid fa-inbox" aria-hidden="true"></i>
-                  <h2 class="no-session-title">这个会话没有可加载的消息</h2>
+                  <h2 class="no-session-title">这个会话的消息暂时没加载出来</h2>
                   <p class="no-session-sub">
-                    会话记录还在,但关联的消息暂时取不到。你可以直接发消息开始新的对话。
+                    可能是网络波动导致历史读取失败。点下面重试,或直接发消息继续对话。
                   </p>
+                  <!-- 2026-11-13:补重试入口(原先只能干等) -->
+                  <ui-button variant="primary" size="sm" (click)="retryOpen()">
+                    重新加载
+                  </ui-button>
                 </div>
                 } @else {
                 <!--

+ 9 - 3
src/app/mobile/pages/chat/chat.ts

@@ -193,11 +193,17 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
                 description="会话记录还在,但服务器上找不到它的任何消息内容。"
               />
             } @else if (chat.emptyReason() === 'unloaded') {
-              <!-- 有会话但历史取不到(2026-11-09):不能伪装成「开始新的对话」 -->
+              <!--
+                有会话但历史取不到(2026-11-09):不能伪装成「开始新的对话」。
+                🔴 2026-11-13 补「重试」(真机实测):移动网络下回溯请求可能失败,
+                   原先没有重试入口,用户只能一直看着这句话。
+              -->
               <app-m-empty
                 icon="fa-solid fa-inbox"
-                title="这个会话没有可加载的消息"
-                description="会话记录还在,但关联的消息暂时取不到。可以直接发消息开始新的对话。"
+                title="这个会话的消息暂时没加载出来"
+                description="可能是网络波动导致历史读取失败。点下面重试,或直接发消息继续对话。"
+                [retryable]="true"
+                (retry)="retry()"
               />
             } @else {
               <app-m-empty