Merge branch 'dev' into test
This commit is contained in:
@@ -47,7 +47,10 @@
|
|||||||
.bubbleRowUser {
|
.bubbleRowUser {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-end;
|
/* 与 AI 行保持一致:头像顶部对齐气泡首行。
|
||||||
|
* 原 flex-end 在气泡多行换行时会把头像贴到气泡底部(视觉上像 "脚注"),
|
||||||
|
* 跟 AI 行不对称,改成 flex-start 跟 AI 行对齐方式统一 */
|
||||||
|
align-items: flex-start;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
gap: var(--spacing-1, 4px);
|
gap: var(--spacing-1, 4px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* PwaInstallOverlay PWA 安装提示触发器
|
* PwaInstallOverlay PWA 安装提示触发器
|
||||||
*
|
*
|
||||||
* 行为:
|
* 行为:
|
||||||
* - 检查 PWA 支持
|
* - 检查 PWA 支持(pwaUtil.isSupported)
|
||||||
|
* - 检查 PWA 是否已安装(pwaUtil.isInstalled)—— 已安装则不弹
|
||||||
* - 检查每日是否已显示(使用 AppStorage 持久化)
|
* - 检查每日是否已显示(使用 AppStorage 持久化)
|
||||||
* - 延迟 3.5s 后弹出 PwaInstallDialog
|
* - 延迟 3.5s 后弹出 PwaInstallDialog
|
||||||
* - 生产环境有效;开发环境 1s 后立即弹出(测试用)
|
* - 生产环境有效;开发环境 1s 后立即弹出(测试用)
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { SpAsyncUtil } from "@/utils/storage";
|
import { SpAsyncUtil } from "@/utils/storage";
|
||||||
|
import { pwaUtil } from "@/utils/pwa";
|
||||||
|
|
||||||
import { PwaInstallDialog } from "./pwa-install-dialog";
|
import { PwaInstallDialog } from "./pwa-install-dialog";
|
||||||
import styles from "./pwa-install-overlay.module.css";
|
import styles from "./pwa-install-overlay.module.css";
|
||||||
@@ -25,21 +27,29 @@ export function PwaInstallOverlay() {
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
// 检查每日是否已显示
|
// 0) PWA 支持 / 安装 双重门禁(两种环境都检查):
|
||||||
|
// isSupported() 检查 serviceWorker + window(SSR 安全)
|
||||||
|
// isInstalled() 检查 standalone display-mode(避免给已安装用户再弹)
|
||||||
|
if (!pwaUtil.isSupported()) return;
|
||||||
|
if (pwaUtil.isInstalled()) return;
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
if (!isDev) {
|
||||||
|
// 生产环境:每日只弹一次(防骚扰)
|
||||||
const lastResult = await SpAsyncUtil.getString(PWA_DIALOG_KEY);
|
const lastResult = await SpAsyncUtil.getString(PWA_DIALOG_KEY);
|
||||||
if (!lastResult.success) return;
|
if (!lastResult.success) return;
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
if (lastResult.data === today) return; // 今天已显示过
|
if (lastResult.data === today) return; // 今天已显示过
|
||||||
|
}
|
||||||
|
// 开发环境:跳过"每日一次"门禁,每次进入都弹,方便 UI 测试
|
||||||
|
|
||||||
// 检查 PWA 支持
|
// 开发环境 1s 后弹(快);生产环境 3.5s 后弹(让用户先看到聊天内容)
|
||||||
if (typeof window === "undefined") return;
|
const delay = isDev ? 1000 : 3500;
|
||||||
if (!("serviceWorker" in navigator)) return;
|
|
||||||
|
|
||||||
// 延迟 3.5s 后显示
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showPwaDialog();
|
showPwaDialog();
|
||||||
}, 3500);
|
}, delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
void init();
|
void init();
|
||||||
@@ -80,6 +90,16 @@ function PwaInstallDialogMount({
|
|||||||
reactRoot.unmount();
|
reactRoot.unmount();
|
||||||
rootEl.remove();
|
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();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
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<boolean> {
|
||||||
|
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();
|
||||||
Reference in New Issue
Block a user