diff --git a/src/app/chat/components/chat-area.module.css b/src/app/chat/components/chat-area.module.css index f66692ad..22e6d7fa 100644 --- a/src/app/chat/components/chat-area.module.css +++ b/src/app/chat/components/chat-area.module.css @@ -47,7 +47,10 @@ .bubbleRowUser { display: flex; flex-direction: row; - align-items: flex-end; + /* 与 AI 行保持一致:头像顶部对齐气泡首行。 + * 原 flex-end 在气泡多行换行时会把头像贴到气泡底部(视觉上像 "脚注"), + * 跟 AI 行不对称,改成 flex-start 跟 AI 行对齐方式统一 */ + align-items: flex-start; align-self: flex-end; gap: var(--spacing-1, 4px); } diff --git a/src/app/chat/components/pwa-install-overlay.tsx b/src/app/chat/components/pwa-install-overlay.tsx index 2014f2be..b7b32616 100644 --- a/src/app/chat/components/pwa-install-overlay.tsx +++ b/src/app/chat/components/pwa-install-overlay.tsx @@ -3,7 +3,8 @@ * PwaInstallOverlay PWA 安装提示触发器 * * 行为: - * - 检查 PWA 支持 + * - 检查 PWA 支持(pwaUtil.isSupported) + * - 检查 PWA 是否已安装(pwaUtil.isInstalled)—— 已安装则不弹 * - 检查每日是否已显示(使用 AppStorage 持久化) * - 延迟 3.5s 后弹出 PwaInstallDialog * - 生产环境有效;开发环境 1s 后立即弹出(测试用) @@ -14,6 +15,7 @@ import { useEffect } from "react"; import { SpAsyncUtil } from "@/utils/storage"; +import { pwaUtil } from "@/utils/pwa"; import { PwaInstallDialog } from "./pwa-install-dialog"; import styles from "./pwa-install-overlay.module.css"; @@ -25,21 +27,29 @@ export function PwaInstallOverlay() { let mounted = true; const init = async () => { - // 检查每日是否已显示 - const lastResult = await SpAsyncUtil.getString(PWA_DIALOG_KEY); - if (!lastResult.success) return; - const today = new Date().toISOString().slice(0, 10); - if (lastResult.data === today) return; // 今天已显示过 + // 0) PWA 支持 / 安装 双重门禁(两种环境都检查): + // isSupported() 检查 serviceWorker + window(SSR 安全) + // isInstalled() 检查 standalone display-mode(避免给已安装用户再弹) + if (!pwaUtil.isSupported()) return; + if (pwaUtil.isInstalled()) return; - // 检查 PWA 支持 - if (typeof window === "undefined") return; - if (!("serviceWorker" in navigator)) return; + const isDev = process.env.NODE_ENV === "development"; - // 延迟 3.5s 后显示 + if (!isDev) { + // 生产环境:每日只弹一次(防骚扰) + const lastResult = await SpAsyncUtil.getString(PWA_DIALOG_KEY); + if (!lastResult.success) return; + const today = new Date().toISOString().slice(0, 10); + if (lastResult.data === today) return; // 今天已显示过 + } + // 开发环境:跳过"每日一次"门禁,每次进入都弹,方便 UI 测试 + + // 开发环境 1s 后弹(快);生产环境 3.5s 后弹(让用户先看到聊天内容) + const delay = isDev ? 1000 : 3500; setTimeout(() => { if (!mounted) return; showPwaDialog(); - }, 3500); + }, delay); }; void init(); @@ -80,6 +90,16 @@ function PwaInstallDialogMount({ reactRoot.unmount(); rootEl.remove(); }} + onInstall={async () => { + // 走 pwaUtil.install(): + // - 挂 beforeinstallprompt 监听(如未挂) + // - 等待 deferred 事件 ready(3s 超时) + // - 调浏览器原生 prompt + // - 返 "accepted" / "dismissed" / "unavailable" + // 当前不消费返回值 —— 用户已经看到 dialog 点了 OK,是否真装上是浏览器的事。 + // 后续可在此接 metrics 上报(pwa_accepted / pwa_dismissed / pwa_unavailable)。 + void pwaUtil.install(); + }} /> ); } diff --git a/src/utils/pwa.ts b/src/utils/pwa.ts new file mode 100644 index 00000000..835f1b04 --- /dev/null +++ b/src/utils/pwa.ts @@ -0,0 +1,173 @@ +"use client"; +/** + * PwaUtil — PWA 工具类 + * + * 三个核心能力(与 [src/hooks/use-pwa-install.ts] 对齐,但面向非 React 代码): + * - `isSupported()` 浏览器是否支持 PWA 安装(serviceWorker + manifest) + * - `isInstalled()` PWA 是否已安装为 standalone(避免重复提示) + * - `install()` 触发浏览器原生安装流程(`beforeinstallprompt`) + * + * 设计要点: + * - SSR 安全:所有方法 `typeof window === "undefined"` 守卫,SSR 全部返 false / "unavailable" + * - 内部缓存 `beforeinstallprompt` 事件对象;install() 时如有缓存直接 prompt,否则等 + * 事件触发(带超时),超时未到则返 "unavailable" + * - install() 用完一次 deferred 后清空 —— 同一事件只能 prompt 一次(浏览器规范) + * - 与 `usePwaInstall` hook 并存:hook 用于 React 组件内部监听 state, + * 本 util 用于业务逻辑(如 ChatArea 想自动触发安装)—— 两者不冲突 + * + * 已知浏览器差异(2026/06): + * - iOS Safari: 不发 `beforeinstallprompt`,需用户手动 Share → Add to Home Screen, + * 本 util 在 iOS 上 isSupported()=true 但 install() 永远返 "unavailable" + * - 已安装的 PWA: 浏览器不发 `beforeinstallprompt`,isInstalled() 走 standalone 检测 + */ + +// `beforeinstallprompt` 事件 —— 浏览器非标准但主流都支持。 +// 类型参考 MDN: https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +/** install() 等待 beforeinstallprompt 事件的最大时长(ms) */ +const BEFORE_INSTALL_PROMPT_TIMEOUT_MS = 3000; + +export class PwaUtil { + // 模块级状态(单例内)—— 跨多次 install() 调用共享 + private deferred: BeforeInstallPromptEvent | null = null; + private listenersAttached = false; + + // ===== 公共 API ===== + + /** + * 检查当前浏览器环境是否支持 PWA 安装。 + * + * 判定:存在 `window.navigator.serviceWorker`。 + * 注意:这只是"能力"层面的检测;具体能否触发安装还取决于 manifest、 + * HTTPS 上下文、用户参与度(30s+ 互动)等因素 —— 这些由 `install()` 处理。 + */ + isSupported(): boolean { + if (typeof window === "undefined") return false; + if (typeof navigator === "undefined") return false; + return "serviceWorker" in navigator; + } + + /** + * 检查 PWA 是否已经以 standalone 模式安装。 + * + * 判定: + * - Android / Chrome: `window.matchMedia("(display-mode: standalone)").matches` + * - iOS Safari: `navigator.standalone === true` + * + * 已安装时不应再弹 install 提示。 + */ + isInstalled(): boolean { + if (typeof window === "undefined") return false; + + // Android / Chrome / Edge: display-mode media query + if ( + typeof window.matchMedia === "function" && + window.matchMedia("(display-mode: standalone)").matches + ) { + return true; + } + + // iOS Safari: 私有属性 + const navAny = navigator as Navigator & { standalone?: boolean }; + if (navAny.standalone === true) { + return true; + } + + return false; + } + + /** + * 触发 PWA 安装(浏览器原生安装提示)。 + * + * 返回值: + * - `"accepted"` 用户接受了安装 + * - `"dismissed"` 用户关闭了提示 + * - `"unavailable"` 不支持 / 已安装 / 超时未收到 beforeinstallprompt + * + * 流程: + * 1) 不支持 / 已安装 → 直接返 "unavailable" + * 2) 挂 beforeinstallprompt 监听(懒初始化,挂一次) + * 3) 如已有 deferred 事件 → 直接 prompt + * 4) 否则等最多 BEFORE_INSTALL_PROMPT_TIMEOUT_MS,事件到了再 prompt + * 5) 用完一次 deferred 清空(浏览器规范:同一事件只能 prompt 一次) + */ + async install(): Promise<"accepted" | "dismissed" | "unavailable"> { + if (!this.isSupported()) { + return "unavailable"; + } + if (this.isInstalled()) { + return "unavailable"; + } + + // 挂监听(首次 install() 调用时挂上;之后重复调用复用同一 listener) + this.attachListener(); + + // 如已有缓存的 deferred 直接用;否则等事件触发 + if (!this.deferred) { + const got = await this.waitForDeferred(); + if (!got) return "unavailable"; + } + + const deferred = this.deferred; + if (!deferred) return "unavailable"; + this.deferred = null; // 一次性 —— 浏览器规范:同一事件只能 prompt 一次 + + try { + await deferred.prompt(); + const choice = await deferred.userChoice; + return choice.outcome; + } catch { + return "unavailable"; + } + } + + // ===== 私有助手 ===== + + /** + * 挂 beforeinstallprompt 监听(仅首次 install() 调用时挂) + * - 必须在客户端环境(isSupported() 已 guard) + * - preventDefault() 阻止浏览器自动弹(让上层控制时机) + */ + private attachListener(): void { + if (this.listenersAttached) return; + if (typeof window === "undefined") return; + + const handler = (e: Event) => { + // 阻止浏览器默认行为(让应用自己决定何时 prompt) + e.preventDefault(); + this.deferred = e as BeforeInstallPromptEvent; + }; + window.addEventListener("beforeinstallprompt", handler); + this.listenersAttached = true; + } + + /** + * 轮询等待 deferred 事件就绪(带超时) + * - 100ms 间隔轮询,足够响应且不阻塞主线程 + * - 超时返 false —— 调用方走 "unavailable" 兜底 + */ + private waitForDeferred(timeoutMs: number = BEFORE_INSTALL_PROMPT_TIMEOUT_MS): Promise { + return new Promise((resolve) => { + const start = Date.now(); + const tick = () => { + if (this.deferred) { + resolve(true); + return; + } + if (Date.now() - start > timeoutMs) { + resolve(false); + return; + } + setTimeout(tick, 100); + }; + tick(); + }); + } +} + +/** 全局单例(与 `deviceIdentifier` / `SpAsyncUtil` 命名约定一致) */ +export const pwaUtil = new PwaUtil(); \ No newline at end of file