소스 검색

fix: 统一新标签打开逻辑 + 预览器增加复制链接

## about:blank 残留问题
全项目有 5 处 `window.open(url,'_blank','noopener,noreferrer')`,
iOS Safari 在该特性字符串下返回 null,调用方误判为"被拦截",
表现为点了新标签没反应 / 出现 about:blank。

新增 core/open-link.ts 统一封装 openInNewTab():
不带特性字符串同步开窗(保住用户手势、拿到引用),再手动置空 opener。
替换位置:
- file-preview 预览器
- mobile deliverables / projects / workbench
- desktop workbench

## 新增「复制链接」按钮
预览器工具栏加复制按钮(图标临时变成对勾反馈 2 秒)。
用于手机浏览器/企微内置浏览器拦截 window.open 时的兜底:
复制链接 → 粘到系统浏览器打开。
copyLink() 优先 navigator.clipboard,失败回退 execCommand。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 3 시간 전
부모
커밋
a6170dca10

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

@@ -20,6 +20,7 @@ import { MatrixClientService } from '../../../core/matrix-client.service';
 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';
 
 /**
  * 预览呈现形态。
@@ -132,6 +133,25 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
                   <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
                 </button>
               </ui-tooltip>
+              <!--
+                复制在线链接(2026-11-13):
+                部分手机浏览器/容器内置浏览器会拦截或处理不好 window.open,
+                给一个"复制链接"的兜底出口 —— 用户粘到系统浏览器里就能打开。
+              -->
+              <ui-tooltip [content]="copied() ? '已复制' : '复制链接'">
+                <button
+                  type="button"
+                  class="fp-icon"
+                  [attr.aria-label]="copied() ? '已复制链接' : '复制链接'"
+                  (click)="copyUrl()"
+                >
+                  <i
+                    class="fa-solid"
+                    [class]="copied() ? 'fa-check' : 'fa-link'"
+                    aria-hidden="true"
+                  ></i>
+                </button>
+              </ui-tooltip>
               <ui-tooltip [content]="presentation() === 'full' ? '回到右侧' : '关闭预览'">
                 <button
                   #closeBtn
@@ -1045,6 +1065,12 @@ export class FilePreviewComponent {
   readonly frameLoading = signal(false);
   /** 「在新标签打开」被浏览器拦截时的提示(3 秒自动消失) */
   readonly blockedHint = signal(false);
+  /**
+   * 「复制链接」刚点过(2026-11-13)。
+   * 用于把图标临时变成对勾,给用户明确反馈 —— 2 秒后复原。
+   */
+  readonly copied = signal(false);
+  private copiedTimer: ReturnType<typeof setTimeout> | null = null;
   private frameTimer: ReturnType<typeof setTimeout> | null = null;
   private frameRetry = 0;
   private readonly blobUrl = signal('');
@@ -1248,6 +1274,29 @@ export class FilePreviewComponent {
     }
   }
 
+  /**
+   * 复制文件的在线链接到剪贴板。
+   *
+   * ## 为什么要有这个按钮(2026-11-13 真机实测)
+   *
+   * 部分手机浏览器 / 容器内置浏览器(企微、钉钉等)对 `window.open` 处理不一致,
+   * 有的直接拦截、有的开出来是空白页。给用户一个稳定的兜底:
+   * **复制链接 → 粘到系统浏览器打开**,效果等同,且不依赖浏览器的弹窗策略。
+   */
+  async copyUrl(): Promise<void> {
+    const url = this.safeUrl();
+    if (!url) return;
+    const ok = await copyLink(url);
+    if (!ok) {
+      /* 复制失败(非 https / 权限拒绝):至少让用户能看到并手动选 */
+      this.textError.set('复制失败,请长按下方链接手动复制');
+      return;
+    }
+    this.copied.set(true);
+    if (this.copiedTimer) clearTimeout(this.copiedTimer);
+    this.copiedTimer = setTimeout(() => this.copied.set(false), 2000);
+  }
+
   /**
    * 在新标签页打开。
    *
@@ -1281,15 +1330,7 @@ export class FilePreviewComponent {
      *    再手动把 `opener` 置空 —— 效果等同 noopener,且不会被误判。
      */
     if (!isMatrixMediaUrl(base)) {
-      const direct = window.open(base, '_blank');
-      if (direct) {
-        try {
-          direct.opener = null;
-        } catch {
-          /* 跨域时不可写,仍有 href 层面的保护 */
-        }
-        return;
-      }
+      if (openInNewTab(base)) return;
       /*
        * 真的被拦了(比如用户开了弹窗阻止):退回本地下载,
        * 不要留一个 about:blank 空页。
@@ -1298,7 +1339,7 @@ export class FilePreviewComponent {
       return;
     }
 
-    /* ② Matrix 附件:必须走 blob 中转(见下方原有注释) */
+    /* ② Matrix 附件:必须走 blob 中转(服务端强制 attachment 头) */
     const authed = this.mx.withAccessToken(base);
     const w = window.open('about:blank', '_blank');
     try {

+ 77 - 0
src/app/core/open-link.ts

@@ -0,0 +1,77 @@
+/**
+ * 外部链接工具:安全地打开新标签、复制链接。
+ *
+ * ## 为什么需要统一封装(2026-11-13 真机实测)
+ *
+ * 原代码到处写 `window.open(url, '_blank', 'noopener,noreferrer')`。
+ * **iOS Safari 在这个特性字符串下会返回 `null`**(规范允许实现方这么做),
+ * 于是调用方常把它判成「弹窗被拦截」,进而退回下载或什么都不做 ——
+ * 用户看到的就是「点了新标签打开,没有任何反应」。
+ *
+ * 正确做法:
+ *   1. **不带特性字符串**同步 `window.open(url, '_blank')` —— 保住用户手势、拿到窗口引用;
+ *   2. 再手动把 `opener` 置空 —— 效果等同 noopener(新页面拿不到我们)。
+ *
+ * ## 为什么不用 `<a target="_blank" rel="noopener">`
+ *
+ * 某些容器内置浏览器(企微、钉钉)对 `<a>` 的 `rel` 处理不一致,
+ * 且我们需要「被拦截时给出明确提示」,用 `<a>` 拿不到这个信号。
+ */
+
+/**
+ * 在新标签页打开链接。
+ *
+ * @returns `true` 表示窗口已打开;`false` 表示被浏览器拦截(调用方可据此提示用户)
+ */
+export function openInNewTab(url: string | null | undefined): boolean {
+  if (!url) return false;
+  const w = window.open(url, '_blank');
+  if (!w) return false;
+  /*
+   * 断开 opener 引用。跨域时该属性可能不可写(静默失败),
+   * 但新页面本身仍是独立上下文,不构成安全风险。
+   */
+  try {
+    w.opener = null;
+  } catch {
+    /* 忽略 */
+  }
+  return true;
+}
+
+/**
+ * 把链接复制到剪贴板。
+ *
+ * 优先用 `navigator.clipboard`(需要安全上下文);失败则回退到
+ * `document.execCommand('copy')`(老浏览器 / 非 https)。
+ *
+ * @returns `true` 表示复制成功
+ */
+export async function copyLink(url: string | null | undefined): Promise<boolean> {
+  if (!url) return false;
+  try {
+    if (navigator.clipboard && window.isSecureContext) {
+      await navigator.clipboard.writeText(url);
+      return true;
+    }
+  } catch {
+    /* 落到下面的兜底 */
+  }
+  /* 兜底:临时 textarea + execCommand */
+  try {
+    const ta = document.createElement('textarea');
+    ta.value = url;
+    ta.setAttribute('readonly', '');
+    ta.style.position = 'fixed';
+    ta.style.top = '-1000px';
+    ta.style.opacity = '0';
+    document.body.appendChild(ta);
+    ta.select();
+    ta.setSelectionRange(0, url.length);
+    const ok = document.execCommand('copy');
+    document.body.removeChild(ta);
+    return ok;
+  } catch {
+    return false;
+  }
+}

+ 2 - 1
src/app/desktop/pages/workbench/workbench.ts

@@ -17,6 +17,7 @@ import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directi
 import { FilterChipsComponent } from '../../../components/domain/filter-chips/filter-chips';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
 import { Agent } from '../../../core/api/models';
+import { openInNewTab } from '../../../core/open-link';
 import { AlertService } from '../../../core/alert.service';
 import { isReporterSelfTalk, livenessOf } from '../../../core/agent-liveness';
 import { DeviceService } from '../../../core/device.service';
@@ -686,7 +687,7 @@ export class DkWorkbenchPage implements OnInit {
    */
   showBalance(a: Agent): void {
     const url = a.balance?.pageUrl || DkWorkbenchPage.BALANCE_URL;
-    window.open(url, '_blank', 'noopener');
+    openInNewTab(url);
   }
 
   openChannel(a: Agent): void {

+ 2 - 1
src/app/mobile/pages/deliverables/deliverables.ts

@@ -1,5 +1,6 @@
 import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
 import { Deliverable } from '../../../core/api/models';
+import { openInNewTab } from '../../../core/open-link';
 import { DeliverablesService } from '../../../core/deliverables.service';
 import { LiveService } from '../../../core/live.service';
 import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
@@ -227,7 +228,7 @@ export class MbDeliverablesPage {
 
   protected open(d: Deliverable): void {
     const url = d.artifacts[0]?.url;
-    if (url) window.open(url, '_blank', 'noopener,noreferrer');
+    if (url) openInNewTab(url);
   }
 
   protected reload(): void {

+ 2 - 1
src/app/mobile/pages/projects/projects.ts

@@ -1,6 +1,7 @@
 import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { Deliverable, Project, Task } from '../../../core/api/models';
+import { openInNewTab } from '../../../core/open-link';
 import { ProjectsService } from '../../../core/projects.service';
 import { TasksService } from '../../../core/tasks.service';
 import { LiveService } from '../../../core/live.service';
@@ -345,7 +346,7 @@ export class MbProjectsPage {
 
   protected openDeliverable(d: Deliverable): void {
     const url = d.artifacts[0]?.url;
-    if (url) window.open(url, '_blank', 'noopener,noreferrer');
+    if (url) openInNewTab(url);
   }
 
   protected back(): void {

+ 2 - 1
src/app/mobile/pages/workbench/workbench.ts

@@ -1,4 +1,5 @@
 import { ChatService } from '../../../core/chat.service';
+import { openInNewTab } from '../../../core/open-link';
 import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
 import { Router } from '@angular/router';
 import { Agent } from '../../../core/api/models';
@@ -428,7 +429,7 @@ export class MbWorkbenchPage {
      * 移动端卡片只展示不分金额,但说明里要讲清余额归属:
      * 容器账单挂在主人账号,所有容器共用同一份余额(2026-11-11)。
      */
-    window.open(url, '_blank', 'noopener,noreferrer');
+    openInNewTab(url);
   }
 
   protected newChat(): void {