From fb48d827f74df73cf41d2af72e90abe67263c4f0 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:48:08 +0800 Subject: [PATCH] fix(stats): mount the panel only on analytics pages The stats content script runs on every chatgpt.com page because SPA route changes do not re-inject content scripts. Mounting relied on WXT's autoMount, which only requires the anchor selector to resolve, and the fallback selectors match generic layout containers that also exist in the chat UI. The panel therefore appeared at the bottom of normal chat pages while waiting for analytics data that never arrives. Gate mounting on the analytics route and replace autoMount with an explicit mount/unmount sync. wxt:locationchange fires before the URL commits, so unmounting uses the event's destination path; entering the analytics route mounts on the first render mutation after the commit. Remount when the official container is replaced, and keep the panel when only its children are re-ordered. The two exact match patterns are redundant with chatgpt.com/*, which the SPA requires, so the runtime route check is the single source of truth. Fixes #2 --- entrypoints/stats.content/index.ts | 57 ++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/entrypoints/stats.content/index.ts b/entrypoints/stats.content/index.ts index 22ab8cd..7e1f69e 100644 --- a/entrypoints/stats.content/index.ts +++ b/entrypoints/stats.content/index.ts @@ -6,12 +6,22 @@ import App from './App.vue'; // 优先匹配新版 OpenAI Analytics 标签页卡片容器,同时兼容回退选择器与旧版路径 const ANALYTICS_ANCHOR_SELECTOR = 'div[class*="gap-[22px]"], div:not([hidden]) > div.flex.flex-col > div.flex.w-full.flex-col, div.pb-8:nth-child(2)'; +/** + * 官方统计页面路径:新版设置页与旧版 Codex 路径。 + * ChatGPT 是 SPA,路由切换不会重新注入内容脚本,因此必须在运行时按路径判定, + * 不能依赖 `matches` 完成页面过滤。 + */ +const ANALYTICS_PAGE_PATHS = ['/settings/usage', '/codex/cloud/settings/analytics']; + +function isAnalyticsPage(pathname: string): boolean { + const path = pathname.replace(/\/+$/, ''); + return ANALYTICS_PAGE_PATHS.includes(path); +} + export default defineContentScript({ - matches: [ - 'https://chatgpt.com/settings/usage*', - 'https://chatgpt.com/codex/cloud/settings/analytics*', - 'https://chatgpt.com/*', - ], + // 必须匹配所有 ChatGPT 页面:SPA 站内跳转不会重新注入内容脚本。 + // 页面过滤由 main() 中的 isAnalyticsPage() 在运行时完成,不再重复声明精确路径。 + matches: ['https://chatgpt.com/*'], cssInjectionMode: 'ui', async main(ctx) { const ui = await createShadowRootUi(ctx, { @@ -39,7 +49,7 @@ export default defineContentScript({ const app = createApp(App); app.mount(mount); - return { app, mount, observer }; + return { app, mount, observer, container }; }, onRemove: (mounted) => { mounted?.observer.disconnect(); @@ -48,6 +58,39 @@ export default defineContentScript({ }, }); - ui.autoMount(); + /** + * 同步面板的挂载状态: + * - 非统计页(例如普通聊天页)一律不挂载;聊天页也存在满足选择器的通用布局容器, + * 只看锚点会把面板挂到聊天界面底部。 + * - 统计页等待官方卡片容器渲染完成后再挂载。 + * - 官方卡片容器被整体替换时重新挂载;仅子节点重排时交给 onMount 里的容器观察器处理。 + * + * `wxt:locationchange` 在 URL 提交前触发,此时 location 仍是旧地址,因此由调用方传入目标路径。 + */ + function syncPanel(pathname = window.location.pathname) { + const onAnalyticsPage = isAnalyticsPage(pathname); + + if (ui.mounted && (!onAnalyticsPage || !ui.mounted.container?.isConnected)) { + ui.remove(); + } + + if (ui.mounted || !onAnalyticsPage) return; + if (!document.querySelector(ANALYTICS_ANCHOR_SELECTOR)) return; + + ui.mount(); + } + + // SPA 路由切换不会重新注入脚本。离开统计页立即卸载;进入统计页时 URL 尚未提交, + // 交给官方页面渲染后的 DOM 变更挂载,避免把面板挂到上一个页面的布局容器里。 + ctx.addEventListener(window, 'wxt:locationchange', (event) => { + if (!isAnalyticsPage(event.newUrl.pathname)) syncPanel(event.newUrl.pathname); + }); + + // 统计页的卡片容器由官方页面异步渲染;容器被替换时也需要重新挂载。 + const renderObserver = new MutationObserver(() => syncPanel()); + renderObserver.observe(document.documentElement, { childList: true, subtree: true }); + ctx.onInvalidated(() => renderObserver.disconnect()); + + syncPanel(); }, });