From 26cacb7470f05c06b35bbb89360426909779491e Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Tue, 14 Apr 2026 18:16:18 +0000 Subject: [PATCH 1/4] =?UTF-8?q?refactor(analytics):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=20lib/analytics=20=E9=80=9A=E7=94=A8=E5=9F=8B=E7=82=B9=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 lib/analytics.ts,封装 trackEvent 函数和 useAnalytics hook: - 复用 POST /api/analytics 接口,携带 satoken 关联用户身份 - 失败静默,不影响用户主流程 - useAnalytics 返回 memoized trackEvent 避免重复引用 新增 DocsPageViewTracker 组件,监听路由变化上报 page_view 事件, 带 sessionStorage + 内存双重去重防止重复上报。 --- app/components/DocsPageViewTracker.tsx | 60 +++++++++----------------- lib/analytics.ts | 48 +++++++++++++++++++++ 2 files changed, 68 insertions(+), 40 deletions(-) create mode 100644 lib/analytics.ts diff --git a/app/components/DocsPageViewTracker.tsx b/app/components/DocsPageViewTracker.tsx index 32c9b119..8c6ac578 100644 --- a/app/components/DocsPageViewTracker.tsx +++ b/app/components/DocsPageViewTracker.tsx @@ -1,59 +1,39 @@ "use client"; +import { useEffect, useRef } from "react"; import { usePathname } from "next/navigation"; -import { useEffect } from "react"; +import { trackEvent } from "@/lib/analytics"; /** - * 文档页面访问埋点组件。 - * - * 放在 app/docs/layout.tsx 下,pathname 变化时向自家 /api/analytics 上报一次 page_view, - * 供将来基于 AnalyticsEvent 表做文档热度分析(当前 A-2 功能的热榜是用 GA4 数据,此处并行积累自家数据)。 - * - * 去重策略:同一浏览器会话内同一 path 只报一次(sessionStorage key = "pv_reported:")。 - * 为什么用 sessionStorage 不用 localStorage:关闭标签页后应当算新会话,否则长期复访的用户会被严重低估。 - * - * 无返回 UI(return null),仅作副作用组件使用。 + * 文档页 PV 埋点组件。 + * 挂载在 docs layout 下,监听路由变化上报 page_view 事件。 + * 用 sessionStorage 去重:同一 session 内同一路径只上报一次。 */ export function DocsPageViewTracker() { const pathname = usePathname(); + // 记录上次上报的路径,避免 StrictMode 下双渲染重复发送 + const lastTrackedRef = useRef(null); useEffect(() => { if (!pathname) return; - // 同会话同 path 已上报则跳过,避免刷新/快速切换重复计数。 - // sessionStorage / localStorage 在 Safari 隐私模式、存储禁用、配额超限时会抛错, - // 埋点组件要绝对静默,全部包 try/catch 后降级到"继续上报但不去重"即可。 - const key = `pv_reported:${pathname}`; + const dedupeKey = `pv:${pathname}`; + // sessionStorage 去重:同一 session 内同一路径不重复上报 try { - if (sessionStorage.getItem(key)) return; - sessionStorage.setItem(key, "1"); - } catch { - // storage 不可用,跳过去重继续上报 - } + if (sessionStorage.getItem(dedupeKey)) return; + // 内存去重:防止 React StrictMode 双重 effect 重复调用 + if (lastTrackedRef.current === pathname) return; - // 如果用户登录了,带上 Sa-Token 让后端能把事件关联到 userId;匿名用户后端会写入 userId=null - let token: string | null = null; - if (typeof window !== "undefined") { - try { - token = localStorage.getItem("satoken"); - } catch { - token = null; - } + lastTrackedRef.current = pathname; + sessionStorage.setItem(dedupeKey, "1"); + } catch { + // storage 不可用,继续上报(放弃去重) } - const headers: Record = { - "Content-Type": "application/json", - }; - if (token) headers["x-satoken"] = token; - // 埋点失败静默吞掉:不能因为分析接口挂了影响文档页的正常阅读体验 - fetch("/api/analytics", { - method: "POST", - headers, - body: JSON.stringify({ - eventType: "page_view", - eventData: { path: pathname, title: document.title }, - }), - }).catch(() => {}); + trackEvent("page_view", { + path: pathname, + title: document.title, + }); }, [pathname]); return null; diff --git a/lib/analytics.ts b/lib/analytics.ts new file mode 100644 index 00000000..c78afa44 --- /dev/null +++ b/lib/analytics.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useCallback } from "react"; + +// 从 localStorage 安全读取 satoken,SSR 环境直接返回 null +function getStoredToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem("satoken"); +} + +/** + * 向后端 /api/analytics 发送埋点事件。 + * 失败静默,不抛异常,不影响用户主流程。 + */ +export async function trackEvent( + eventType: string, + eventData?: Record, +): Promise { + try { + const token = getStoredToken(); + const headers: HeadersInit = { "Content-Type": "application/json" }; + // 携带 satoken 让服务端关联用户身份;匿名访问时 header 不传 + if (token) { + headers["satoken"] = token; + } + + await fetch("/api/analytics", { + method: "POST", + headers, + body: JSON.stringify({ eventType, eventData: eventData ?? {} }), + }); + } catch { + // 埋点失败不影响用户操作,静默丢弃 + } +} + +/** + * 在客户端组件中使用的埋点 hook。 + * 返回 memoized 的 trackEvent,避免每次渲染都新建引用。 + */ +export function useAnalytics() { + const track = useCallback( + (eventType: string, eventData?: Record) => + trackEvent(eventType, eventData), + [], + ); + return { trackEvent: track }; +} From e839124eab04e63352e3f0dde955c960a0f2652d Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Tue, 14 Apr 2026 18:16:35 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(analytics):=20=E5=9F=8B=20doc=5Fshare/?= =?UTF-8?q?search=5Fopen/page=5Fview=20=E5=85=B3=E9=94=AE=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DocsPageViewTracker 挂载至 docs layout,路由切换时上报 page_view - CustomSearchDialog:弹窗从关闭→打开时触发 search_open 埋点 - DocShareButton:新增文档页"复制链接"按钮,点击时触发 doc_share 埋点 并将当前 URL 写入剪贴板,放置于文档标题区 EditOnGithub 旁边 --- app/components/CustomSearchDialog.tsx | 70 ++++++++++++++++----------- app/components/DocShareButton.tsx | 51 +++++++++++++++++++ app/docs/[...slug]/page.tsx | 6 ++- app/docs/layout.tsx | 1 + 4 files changed, 98 insertions(+), 30 deletions(-) create mode 100644 app/components/DocShareButton.tsx diff --git a/app/components/CustomSearchDialog.tsx b/app/components/CustomSearchDialog.tsx index c155a586..2d076ba0 100644 --- a/app/components/CustomSearchDialog.tsx +++ b/app/components/CustomSearchDialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { trackEvent } from "@/lib/analytics"; import { useDocsSearch } from "fumadocs-core/search/client"; import { useI18n } from "fumadocs-ui/provider"; import { @@ -57,7 +58,16 @@ export function CustomSearchDialog({ const [tag, setTag] = useState(defaultTag); // Extract onOpenChange to use in dependency array cleanly - const { onOpenChange, ...otherProps } = props; + const { onOpenChange, open, ...otherProps } = props; + + // 记录上次 open 状态,只在从关闭→打开时触发埋点,避免渲染抖动多次上报 + const prevOpenRef = useRef(undefined); + useEffect(() => { + if (open === true && prevOpenRef.current !== true) { + trackEvent("search_open", { path: window.location.pathname }); + } + prevOpenRef.current = open; + }, [open]); const { search, setSearch, query } = useDocsSearch( type === "fetch" @@ -82,7 +92,7 @@ export function CustomSearchDialog({ if (!search) return; const timer = setTimeout(() => { - // Umami 埋点: 搜索结果点击 + // Umami 埋点: 搜索结果点击 if (window.umami) { window.umami.track("search_query", { query: search }); } @@ -103,36 +113,37 @@ export function CustomSearchDialog({ // 使用 useMemo 劫持 search items,注入埋点逻辑 const trackedItems = useMemo(() => { - const data = query.data !== "empty" && query.data ? query.data : defaultItems; + const data = + query.data !== "empty" && query.data ? query.data : defaultItems; if (!data) return []; return data.map((item: unknown, index: number) => { - const searchItem = item as SearchItem; - return { - ...searchItem, - onSelect: (value: string) => { - // Umami 埋点: 搜索结果点击 - if (window.umami) { - window.umami.track("search_result_click", { - query: search, - rank: index + 1, - url: searchItem.url, - }); - } - - // Call original onSelect if it exists - if (searchItem.onSelect) searchItem.onSelect(value); - - // Handle navigation if URL exists - if (searchItem.url) { - // 显式执行路由跳转和关闭弹窗,确保点击行为能够同时触发埋点和导航 - router.push(searchItem.url); - if (onOpenChange) { - onOpenChange(false); - } + const searchItem = item as SearchItem; + return { + ...searchItem, + onSelect: (value: string) => { + // Umami 埋点: 搜索结果点击 + if (window.umami) { + window.umami.track("search_result_click", { + query: search, + rank: index + 1, + url: searchItem.url, + }); + } + + // Call original onSelect if it exists + if (searchItem.onSelect) searchItem.onSelect(value); + + // Handle navigation if URL exists + if (searchItem.url) { + // 显式执行路由跳转和关闭弹窗,确保点击行为能够同时触发埋点和导航 + router.push(searchItem.url); + if (onOpenChange) { + onOpenChange(false); } - }, - }; + } + }, + }; }); }, [query.data, defaultItems, search, router, onOpenChange]); @@ -142,6 +153,7 @@ export function CustomSearchDialog({ onSearchChange={setSearch} isLoading={query.isLoading} onOpenChange={onOpenChange} + open={open} {...otherProps} > diff --git a/app/components/DocShareButton.tsx b/app/components/DocShareButton.tsx new file mode 100644 index 00000000..abd1fee2 --- /dev/null +++ b/app/components/DocShareButton.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; +import { trackEvent } from "@/lib/analytics"; + +/** + * 文档页"复制链接"按钮。 + * 点击后将当前页 URL 写入剪贴板,同时触发 doc_share 埋点。 + */ +export function DocShareButton() { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + const url = window.location.href; + try { + await navigator.clipboard.writeText(url); + setCopied(true); + // 2s 后恢复按钮文案 + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard 不可用时静默失败 + } + + // 埋点在复制动作发生后立即上报,不依赖 clipboard 是否成功 + trackEvent("doc_share", { path: window.location.pathname, url }); + }; + + return ( + + ); +} diff --git a/app/docs/[...slug]/page.tsx b/app/docs/[...slug]/page.tsx index f79a3e4c..019b3e1c 100644 --- a/app/docs/[...slug]/page.tsx +++ b/app/docs/[...slug]/page.tsx @@ -15,6 +15,7 @@ import { DocsAssistant } from "@/app/components/DocsAssistant"; import { LicenseNotice } from "@/app/components/LicenseNotice"; import { PageFeedback } from "@/app/components/PageFeedback"; import { DocHistoryPanel } from "@/app/components/DocHistoryPanel"; +import { DocShareButton } from "@/app/components/DocShareButton"; // Extract clean text content from MDX - no longer used on client/page side // content fetching moved to API route for performance @@ -53,7 +54,10 @@ export default async function DocPage({ params }: Param) {

{page.data.title}

- +
+ + +
diff --git a/app/docs/layout.tsx b/app/docs/layout.tsx index 51fb33d7..d9ad7fd2 100644 --- a/app/docs/layout.tsx +++ b/app/docs/layout.tsx @@ -71,6 +71,7 @@ export default async function Layout({ children }: { children: ReactNode }) { <> {/* Add a class on while in docs to adjust global backgrounds */} + Date: Tue, 14 Apr 2026 19:08:39 +0000 Subject: [PATCH 3/4] =?UTF-8?q?chore(analytics):=20CR=20-=20=E4=BF=AE=20x-?= =?UTF-8?q?satoken=20header=20/=20storage=20try/catch=20/=20timer=20?= =?UTF-8?q?=E6=B8=85=E7=90=86=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot CR #277: - lib/analytics: 客户端 → Next /api/analytics 用 x-satoken(之前用 satoken 导致 resolveUserId 解析不到 userId,uniqueUsers 恒为 0) - lib/analytics: localStorage 读取用 try/catch,Safari 隐私模式不崩 - lib/analytics: headers 用 Record 而不是 HeadersInit 联合类型,可变安全 - DocsPageViewTracker: sessionStorage 读写 try/catch 降级到内存去重 - DocShareButton: useRef 存 setTimeout id,unmount 时 clearTimeout;加 type='button' 避免 form 内误触发提交 - CustomSearchDialog: 注释修正('搜索结果点击' → '搜索词输入') --- app/components/CustomSearchDialog.tsx | 2 +- app/components/DocShareButton.tsx | 16 +++++++++++++--- app/components/DocsPageViewTracker.tsx | 15 ++++++++++----- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/components/CustomSearchDialog.tsx b/app/components/CustomSearchDialog.tsx index 2d076ba0..a004595f 100644 --- a/app/components/CustomSearchDialog.tsx +++ b/app/components/CustomSearchDialog.tsx @@ -92,7 +92,7 @@ export function CustomSearchDialog({ if (!search) return; const timer = setTimeout(() => { - // Umami 埋点: 搜索结果点击 + // Umami 埋点: 搜索词输入(debounce 1s,非搜索结果点击) if (window.umami) { window.umami.track("search_query", { query: search }); } diff --git a/app/components/DocShareButton.tsx b/app/components/DocShareButton.tsx index abd1fee2..f71fdeaa 100644 --- a/app/components/DocShareButton.tsx +++ b/app/components/DocShareButton.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { trackEvent } from "@/lib/analytics"; /** @@ -9,14 +9,23 @@ import { trackEvent } from "@/lib/analytics"; */ export function DocShareButton() { const [copied, setCopied] = useState(false); + // timer ref:每次新点击 / 组件卸载时清掉旧 timer,避免 setState on unmounted + const resetTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + }; + }, []); const handleCopy = async () => { const url = window.location.href; try { await navigator.clipboard.writeText(url); setCopied(true); - // 2s 后恢复按钮文案 - setTimeout(() => setCopied(false), 2000); + // 旧 timer 先清掉,避免连点两次后提前恢复文案 + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + resetTimerRef.current = setTimeout(() => setCopied(false), 2000); } catch { // clipboard 不可用时静默失败 } @@ -27,6 +36,7 @@ export function DocShareButton() { return (