diff --git a/app/components/CustomSearchDialog.tsx b/app/components/CustomSearchDialog.tsx index c155a586..a004595f 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 埋点: 搜索词输入(debounce 1s,非搜索结果点击) 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..f71fdeaa --- /dev/null +++ b/app/components/DocShareButton.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { trackEvent } from "@/lib/analytics"; + +/** + * 文档页"复制链接"按钮。 + * 点击后将当前页 URL 写入剪贴板,同时触发 doc_share 埋点。 + */ +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); + // 旧 timer 先清掉,避免连点两次后提前恢复文案 + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + resetTimerRef.current = setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard 不可用时静默失败 + } + + // 埋点在复制动作发生后立即上报,不依赖 clipboard 是否成功 + trackEvent("doc_share", { path: window.location.pathname, url }); + }; + + return ( + + ); +} diff --git a/app/components/DocsPageViewTracker.tsx b/app/components/DocsPageViewTracker.tsx index 32c9b119..eeb2a065 100644 --- a/app/components/DocsPageViewTracker.tsx +++ b/app/components/DocsPageViewTracker.tsx @@ -1,59 +1,44 @@ "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 可能因为存储禁用 / 配额超限 / Safari 隐私模式抛错; + // 埋点的去重不能因此报错破坏导航,用 try/catch 降级到内存去重 try { - if (sessionStorage.getItem(key)) return; - sessionStorage.setItem(key, "1"); + if (sessionStorage.getItem(dedupeKey)) return; } catch { - // storage 不可用,跳过去重继续上报 + // 读失败时跳过 session 去重,后面的内存去重仍然生效 } + // 内存去重:防止 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; + try { + sessionStorage.setItem(dedupeKey, "1"); + } catch { + // 写失败时下一个 session / 刷新会再报一次,可接受 } - 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/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 */} + , +): Promise { + try { + const token = getStoredToken(); + // 用 Record 而不是 HeadersInit(联合类型),保证可变 + 类型安全 + const headers: Record = { + "Content-Type": "application/json", + }; + // 客户端 → Next 路由必须用 x-satoken(见上方注释) + if (token) { + headers["x-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 }; +}