From 27cb2e2c33eed7f8049ac84a289ec1523e0a2afe Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 3 Jul 2026 18:29:58 +0800 Subject: [PATCH] fix(pwa): show install prompt only once --- .../chat/components/pwa-install-overlay.tsx | 26 +++++++--------- src/data/storage/app/app_storage.ts | 31 +++++++++++++------ src/data/storage/storage_keys.ts | 2 +- src/lib/chat/pwa_install_prompt.ts | 12 +++++++ 4 files changed, 46 insertions(+), 25 deletions(-) create mode 100644 src/lib/chat/pwa_install_prompt.ts diff --git a/src/app/chat/components/pwa-install-overlay.tsx b/src/app/chat/components/pwa-install-overlay.tsx index ed4ca26d..818db4de 100644 --- a/src/app/chat/components/pwa-install-overlay.tsx +++ b/src/app/chat/components/pwa-install-overlay.tsx @@ -6,8 +6,8 @@ * - 等聊天消息数量达到触发阈值后才开始检查 * - 检查 PWA 支持(pwaUtil.isSupported) * - 检查 PWA 是否已安装(pwaUtil.isInstalled)—— 已安装则不弹 - * - 检查每日是否已显示(使用 AppStorage 持久化) - * - 延迟 3.5s 后弹出 PwaInstallDialog + * - 检查是否已经显示过(使用 AppStorage 持久化) + * - 延迟后弹出 PwaInstallDialog * - 生产环境有效;开发环境 1s 后立即弹出(测试用) * * 注意:PWA install prompt API(`beforeinstallprompt`)在浏览器差异较大, @@ -15,12 +15,15 @@ */ import { useEffect } from "react"; -import { BrowserDetector, SpAsyncUtil, pwaUtil } from "@/utils"; +import { + canShowPwaInstallPromptOnce, + recordPwaInstallPromptShown, +} from "@/lib/chat/pwa_install_prompt"; +import { BrowserDetector, pwaUtil } from "@/utils"; import { PwaInstallDialog } from "./pwa-install-dialog"; import styles from "./pwa-install-overlay.module.css"; -const PWA_DIALOG_KEY = "cozsweet:lastPwaDialogShown"; const DEVELOPMENT_DIALOG_DELAY_MS = 1000; const PRODUCTION_DIALOG_DELAY_MS = 1500; @@ -70,22 +73,17 @@ async function shouldShowPwaInstallDialog(isDevelopment: boolean) { if (pwaUtil.isInstalled()) return false; if (BrowserDetector.isInAppBrowser()) return false; - // 开发环境:跳过"每日一次"门禁,每次进入都弹,方便 UI 测试。 + // 开发环境:跳过"只弹一次"门禁,每次进入都弹,方便 UI 测试。 if (isDevelopment) return true; - // 生产环境:每日只弹一次(防骚扰)。 - const lastResult = await SpAsyncUtil.getString(PWA_DIALOG_KEY); - if (!lastResult.success) return false; - - const today = new Date().toISOString().slice(0, 10); - return lastResult.data !== today; + // 生产环境:永久只弹一次(防骚扰)。 + return canShowPwaInstallPromptOnce(); } function showPwaDialog() { if (typeof document === "undefined") return; - // 记录显示时间 - const today = new Date().toISOString().slice(0, 10); - void SpAsyncUtil.setString(PWA_DIALOG_KEY, today); + // 记录已显示,后续不再弹出。 + void recordPwaInstallPromptShown(); // 创建挂载点 const root = document.createElement("div"); diff --git a/src/data/storage/app/app_storage.ts b/src/data/storage/app/app_storage.ts index b6419861..6916b036 100644 --- a/src/data/storage/app/app_storage.ts +++ b/src/data/storage/app/app_storage.ts @@ -4,30 +4,41 @@ * AppStorage 完整实现(基于 unstorage 抽象) * * 对齐 Dart 端 `AppStorage`(lib/data/services/storage/app_storage.dart): - * - PWA 对话框每日展示 / PWA 事件每日上报 / app-info 每日上报 - * - 内部用 `yyyy-MM-dd` 字符串比对实现"每日一次"语义 + * - PWA 对话框一次性展示 / PWA 事件每日上报 / app-info 每日上报 * * 注意:Dart 端是 `static class` 没有接口,本 TS 版本同样保持静态方法风格。 * 通过 SpAsyncUtil 静态类访问底层存储(浏览器 localStorage / SSR memory 自动切换)。 * - * 语义: - * - `canXxx(today)` 返回 `true` 当且仅当"自上次记录以来日期变了" - * (即 stored !== today)。首次调用(key 不存在)也返回 `true`。 - * - `recordXxx(today)` 写入 todayString,后续 `canXxx` 在同一天返回 `false`。 + * PWA 对话框使用布尔标记;每日上报类方法仍使用 `yyyy-MM-dd` 字符串比对。 */ import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils"; import { StorageKeys } from "../storage_keys"; +const LEGACY_PWA_DIALOG_SHOWN_KEYS = [ + "cozsweet:lastPwaDialogShown", + "last_pwa_dialog_shown", +]; + export class AppStorage { // ---- PWA install dialog ---- - static async canShowPwaDialog(todayString: string): Promise> { - return AppStorage.canReportKey(StorageKeys.lastPwaDialogShown, todayString); + static async canShowPwaDialog(): Promise> { + const shownResult = await SpAsyncUtil.getBool(StorageKeys.pwaDialogShown); + if (!shownResult.success) return shownResult; + if (shownResult.data === true) return Result.ok(false); + + for (const key of LEGACY_PWA_DIALOG_SHOWN_KEYS) { + const legacyResult = await SpAsyncUtil.getString(key); + if (!legacyResult.success) return legacyResult; + if (legacyResult.data !== null) return Result.ok(false); + } + + return Result.ok(true); } - static recordPwaDialogShown(todayString: string): Promise> { - return SpAsyncUtil.setString(StorageKeys.lastPwaDialogShown, todayString); + static recordPwaDialogShown(): Promise> { + return SpAsyncUtil.setBool(StorageKeys.pwaDialogShown, true); } // ---- external browser dialog ---- diff --git a/src/data/storage/storage_keys.ts b/src/data/storage/storage_keys.ts index 5bfceea5..2c4408c2 100644 --- a/src/data/storage/storage_keys.ts +++ b/src/data/storage/storage_keys.ts @@ -28,7 +28,7 @@ export const StorageKeys = { pendingChatUnlock: "pending_chat_unlock", // pwa / app info - lastPwaDialogShown: "last_pwa_dialog_shown", + pwaDialogShown: "pwa_dialog_shown", lastExternalBrowserDialogShown: "last_external_browser_dialog_shown", lastPwaEventReported: "last_pwa_event_reported", lastAppInfoReported: "last_app_info_reported", diff --git a/src/lib/chat/pwa_install_prompt.ts b/src/lib/chat/pwa_install_prompt.ts new file mode 100644 index 00000000..693d2e84 --- /dev/null +++ b/src/lib/chat/pwa_install_prompt.ts @@ -0,0 +1,12 @@ +"use client"; + +import { AppStorage } from "@/data/storage/app/app_storage"; + +export async function canShowPwaInstallPromptOnce(): Promise { + const result = await AppStorage.canShowPwaDialog(); + return result.success && result.data; +} + +export function recordPwaInstallPromptShown(): Promise { + return AppStorage.recordPwaDialogShown(); +}