Skip to content

Commit 62746a7

Browse files
authored
feat(analytics): 通用 trackEvent hook 与关键交互埋点 (#277)
* refactor(analytics): 抽出 lib/analytics 通用埋点 hook 新增 lib/analytics.ts,封装 trackEvent 函数和 useAnalytics hook: - 复用 POST /api/analytics 接口,携带 satoken 关联用户身份 - 失败静默,不影响用户主流程 - useAnalytics 返回 memoized trackEvent 避免重复引用 新增 DocsPageViewTracker 组件,监听路由变化上报 page_view 事件, 带 sessionStorage + 内存双重去重防止重复上报。 * feat(analytics): 埋 doc_share/search_open/page_view 关键交互事件 - DocsPageViewTracker 挂载至 docs layout,路由切换时上报 page_view - CustomSearchDialog:弹窗从关闭→打开时触发 search_open 埋点 - DocShareButton:新增文档页"复制链接"按钮,点击时触发 doc_share 埋点 并将当前 URL 写入剪贴板,放置于文档标题区 EditOnGithub 旁边 * chore(analytics): CR - 修 x-satoken header / storage try/catch / timer 清理等 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<string,string> 而不是 HeadersInit 联合类型,可变安全 - DocsPageViewTracker: sessionStorage 读写 try/catch 降级到内存去重 - DocShareButton: useRef 存 setTimeout id,unmount 时 clearTimeout;加 type='button' 避免 form 内误触发提交 - CustomSearchDialog: 注释修正('搜索结果点击' → '搜索词输入') * chore(analytics): CR - trackEvent 用 x-satoken(关键修复) 接前一 commit 补落下的 lib/analytics.ts 改动。Copilot 指出这个是最核心的 bug:resolveUserId 从 x-satoken 读 token,之前传 satoken 永远拿不到 userId, 导致埋点 uniqueUsers 恒为 0。现在修成 x-satoken,配合 storage try/catch 和 headers 类型换成 Record<string,string>。
1 parent f7a47fa commit 62746a7

6 files changed

Lines changed: 190 additions & 68 deletions

File tree

app/components/CustomSearchDialog.tsx

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

3-
import { useEffect, useMemo, useState } from "react";
3+
import { useEffect, useMemo, useRef, useState } from "react";
4+
import { trackEvent } from "@/lib/analytics";
45
import { useDocsSearch } from "fumadocs-core/search/client";
56
import { useI18n } from "fumadocs-ui/provider";
67
import {
@@ -57,7 +58,16 @@ export function CustomSearchDialog({
5758
const [tag, setTag] = useState(defaultTag);
5859

5960
// Extract onOpenChange to use in dependency array cleanly
60-
const { onOpenChange, ...otherProps } = props;
61+
const { onOpenChange, open, ...otherProps } = props;
62+
63+
// 记录上次 open 状态,只在从关闭→打开时触发埋点,避免渲染抖动多次上报
64+
const prevOpenRef = useRef<boolean | undefined>(undefined);
65+
useEffect(() => {
66+
if (open === true && prevOpenRef.current !== true) {
67+
trackEvent("search_open", { path: window.location.pathname });
68+
}
69+
prevOpenRef.current = open;
70+
}, [open]);
6171

6272
const { search, setSearch, query } = useDocsSearch(
6373
type === "fetch"
@@ -82,7 +92,7 @@ export function CustomSearchDialog({
8292
if (!search) return;
8393

8494
const timer = setTimeout(() => {
85-
// Umami 埋点: 搜索结果点击
95+
// Umami 埋点: 搜索词输入(debounce 1s,非搜索结果点击)
8696
if (window.umami) {
8797
window.umami.track("search_query", { query: search });
8898
}
@@ -103,36 +113,37 @@ export function CustomSearchDialog({
103113

104114
// 使用 useMemo 劫持 search items,注入埋点逻辑
105115
const trackedItems = useMemo(() => {
106-
const data = query.data !== "empty" && query.data ? query.data : defaultItems;
116+
const data =
117+
query.data !== "empty" && query.data ? query.data : defaultItems;
107118
if (!data) return [];
108119

109120
return data.map((item: unknown, index: number) => {
110-
const searchItem = item as SearchItem;
111-
return {
112-
...searchItem,
113-
onSelect: (value: string) => {
114-
// Umami 埋点: 搜索结果点击
115-
if (window.umami) {
116-
window.umami.track("search_result_click", {
117-
query: search,
118-
rank: index + 1,
119-
url: searchItem.url,
120-
});
121-
}
122-
123-
// Call original onSelect if it exists
124-
if (searchItem.onSelect) searchItem.onSelect(value);
125-
126-
// Handle navigation if URL exists
127-
if (searchItem.url) {
128-
// 显式执行路由跳转和关闭弹窗,确保点击行为能够同时触发埋点和导航
129-
router.push(searchItem.url);
130-
if (onOpenChange) {
131-
onOpenChange(false);
132-
}
121+
const searchItem = item as SearchItem;
122+
return {
123+
...searchItem,
124+
onSelect: (value: string) => {
125+
// Umami 埋点: 搜索结果点击
126+
if (window.umami) {
127+
window.umami.track("search_result_click", {
128+
query: search,
129+
rank: index + 1,
130+
url: searchItem.url,
131+
});
132+
}
133+
134+
// Call original onSelect if it exists
135+
if (searchItem.onSelect) searchItem.onSelect(value);
136+
137+
// Handle navigation if URL exists
138+
if (searchItem.url) {
139+
// 显式执行路由跳转和关闭弹窗,确保点击行为能够同时触发埋点和导航
140+
router.push(searchItem.url);
141+
if (onOpenChange) {
142+
onOpenChange(false);
133143
}
134-
},
135-
};
144+
}
145+
},
146+
};
136147
});
137148
}, [query.data, defaultItems, search, router, onOpenChange]);
138149

@@ -142,6 +153,7 @@ export function CustomSearchDialog({
142153
onSearchChange={setSearch}
143154
isLoading={query.isLoading}
144155
onOpenChange={onOpenChange}
156+
open={open}
145157
{...otherProps}
146158
>
147159
<SearchDialogOverlay />

app/components/DocShareButton.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"use client";
2+
3+
import { useEffect, useRef, useState } from "react";
4+
import { trackEvent } from "@/lib/analytics";
5+
6+
/**
7+
* 文档页"复制链接"按钮。
8+
* 点击后将当前页 URL 写入剪贴板,同时触发 doc_share 埋点。
9+
*/
10+
export function DocShareButton() {
11+
const [copied, setCopied] = useState(false);
12+
// timer ref:每次新点击 / 组件卸载时清掉旧 timer,避免 setState on unmounted
13+
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
14+
15+
useEffect(() => {
16+
return () => {
17+
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
18+
};
19+
}, []);
20+
21+
const handleCopy = async () => {
22+
const url = window.location.href;
23+
try {
24+
await navigator.clipboard.writeText(url);
25+
setCopied(true);
26+
// 旧 timer 先清掉,避免连点两次后提前恢复文案
27+
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
28+
resetTimerRef.current = setTimeout(() => setCopied(false), 2000);
29+
} catch {
30+
// clipboard 不可用时静默失败
31+
}
32+
33+
// 埋点在复制动作发生后立即上报,不依赖 clipboard 是否成功
34+
trackEvent("doc_share", { path: window.location.pathname, url });
35+
};
36+
37+
return (
38+
<button
39+
type="button"
40+
onClick={handleCopy}
41+
className="inline-flex items-center gap-2 rounded-md px-4 h-11 text-base font-medium hover:bg-muted/80 hover:text-foreground"
42+
aria-label="复制页面链接"
43+
>
44+
<svg
45+
aria-hidden="true"
46+
className="h-5 w-5"
47+
viewBox="0 0 24 24"
48+
fill="none"
49+
stroke="currentColor"
50+
strokeWidth={1.8}
51+
strokeLinecap="round"
52+
strokeLinejoin="round"
53+
>
54+
{/* link 图标 */}
55+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
56+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
57+
</svg>
58+
{copied ? "已复制" : "复制链接"}
59+
</button>
60+
);
61+
}

app/components/DocsPageViewTracker.tsx

Lines changed: 23 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,44 @@
11
"use client";
22

3+
import { useEffect, useRef } from "react";
34
import { usePathname } from "next/navigation";
4-
import { useEffect } from "react";
5+
import { trackEvent } from "@/lib/analytics";
56

67
/**
7-
* 文档页面访问埋点组件。
8-
*
9-
* 放在 app/docs/layout.tsx 下,pathname 变化时向自家 /api/analytics 上报一次 page_view,
10-
* 供将来基于 AnalyticsEvent 表做文档热度分析(当前 A-2 功能的热榜是用 GA4 数据,此处并行积累自家数据)。
11-
*
12-
* 去重策略:同一浏览器会话内同一 path 只报一次(sessionStorage key = "pv_reported:<path>")。
13-
* 为什么用 sessionStorage 不用 localStorage:关闭标签页后应当算新会话,否则长期复访的用户会被严重低估。
14-
*
15-
* 无返回 UI(return null),仅作副作用组件使用。
8+
* 文档页 PV 埋点组件。
9+
* 挂载在 docs layout 下,监听路由变化上报 page_view 事件。
10+
* 用 sessionStorage 去重:同一 session 内同一路径只上报一次。
1611
*/
1712
export function DocsPageViewTracker() {
1813
const pathname = usePathname();
14+
// 记录上次上报的路径,避免 StrictMode 下双渲染重复发送
15+
const lastTrackedRef = useRef<string | null>(null);
1916

2017
useEffect(() => {
2118
if (!pathname) return;
2219

23-
// 同会话同 path 已上报则跳过,避免刷新/快速切换重复计数。
24-
// sessionStorage / localStorage 在 Safari 隐私模式、存储禁用、配额超限时会抛错,
25-
// 埋点组件要绝对静默,全部包 try/catch 后降级到"继续上报但不去重"即可。
26-
const key = `pv_reported:${pathname}`;
20+
const dedupeKey = `pv:${pathname}`;
21+
// sessionStorage 可能因为存储禁用 / 配额超限 / Safari 隐私模式抛错;
22+
// 埋点的去重不能因此报错破坏导航,用 try/catch 降级到内存去重
2723
try {
28-
if (sessionStorage.getItem(key)) return;
29-
sessionStorage.setItem(key, "1");
24+
if (sessionStorage.getItem(dedupeKey)) return;
3025
} catch {
31-
// storage 不可用,跳过去重继续上报
26+
// 读失败时跳过 session 去重,后面的内存去重仍然生效
3227
}
28+
// 内存去重:防止 React StrictMode 双重 effect 重复调用
29+
if (lastTrackedRef.current === pathname) return;
3330

34-
// 如果用户登录了,带上 Sa-Token 让后端能把事件关联到 userId;匿名用户后端会写入 userId=null
35-
let token: string | null = null;
36-
if (typeof window !== "undefined") {
37-
try {
38-
token = localStorage.getItem("satoken");
39-
} catch {
40-
token = null;
41-
}
31+
lastTrackedRef.current = pathname;
32+
try {
33+
sessionStorage.setItem(dedupeKey, "1");
34+
} catch {
35+
// 写失败时下一个 session / 刷新会再报一次,可接受
4236
}
43-
const headers: Record<string, string> = {
44-
"Content-Type": "application/json",
45-
};
46-
if (token) headers["x-satoken"] = token;
4737

48-
// 埋点失败静默吞掉:不能因为分析接口挂了影响文档页的正常阅读体验
49-
fetch("/api/analytics", {
50-
method: "POST",
51-
headers,
52-
body: JSON.stringify({
53-
eventType: "page_view",
54-
eventData: { path: pathname, title: document.title },
55-
}),
56-
}).catch(() => {});
38+
trackEvent("page_view", {
39+
path: pathname,
40+
title: document.title,
41+
});
5742
}, [pathname]);
5843

5944
return null;

app/docs/[...slug]/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { DocsAssistant } from "@/app/components/DocsAssistant";
1515
import { LicenseNotice } from "@/app/components/LicenseNotice";
1616
import { PageFeedback } from "@/app/components/PageFeedback";
1717
import { DocHistoryPanel } from "@/app/components/DocHistoryPanel";
18+
import { DocShareButton } from "@/app/components/DocShareButton";
1819
// Extract clean text content from MDX - no longer used on client/page side
1920
// content fetching moved to API route for performance
2021

@@ -53,7 +54,10 @@ export default async function DocPage({ params }: Param) {
5354
<h1 className="text-3xl font-extrabold tracking-tight md:text-4xl">
5455
{page.data.title}
5556
</h1>
56-
<EditOnGithub href={editUrl} />
57+
<div className="flex items-center gap-2">
58+
<DocShareButton />
59+
<EditOnGithub href={editUrl} />
60+
</div>
5761
</div>
5862
<Mdx components={getMDXComponents()} />
5963
<Contributors entry={contributorsEntry} />

app/docs/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
7171
<>
7272
{/* Add a class on <html> while in docs to adjust global backgrounds */}
7373
<CopyTracking />
74+
<DocsPageViewTracker />
7475
<DocsRouteFlag />
7576
<DocsPageViewTracker />
7677
<DocsLayout

lib/analytics.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"use client";
2+
3+
import { useCallback } from "react";
4+
5+
// 从 localStorage 安全读取 satoken。SSR 环境 / storage 禁用(Safari 隐私模式)均返回 null
6+
function getStoredToken(): string | null {
7+
if (typeof window === "undefined") return null;
8+
try {
9+
return localStorage.getItem("satoken");
10+
} catch {
11+
return null;
12+
}
13+
}
14+
15+
/**
16+
* 向 Next.js 内置 /api/analytics 发送埋点事件。
17+
* 失败静默,不抛异常,不影响用户主流程。
18+
*
19+
* Header 命名注意:/api/analytics 的 resolveUserId 从 `x-satoken` 读取 token(见 lib/server-auth.ts),
20+
* 然后在内部再以 `satoken` header 转发给后端 /auth/me 验证。所以客户端 → Next 这一跳必须用 `x-satoken`,
21+
* 否则 userId 永远解析不到,埋点记录的 uniqueUsers 会恒为 0。
22+
*/
23+
export async function trackEvent(
24+
eventType: string,
25+
eventData?: Record<string, unknown>,
26+
): Promise<void> {
27+
try {
28+
const token = getStoredToken();
29+
// 用 Record<string, string> 而不是 HeadersInit(联合类型),保证可变 + 类型安全
30+
const headers: Record<string, string> = {
31+
"Content-Type": "application/json",
32+
};
33+
// 客户端 → Next 路由必须用 x-satoken(见上方注释)
34+
if (token) {
35+
headers["x-satoken"] = token;
36+
}
37+
38+
await fetch("/api/analytics", {
39+
method: "POST",
40+
headers,
41+
body: JSON.stringify({ eventType, eventData: eventData ?? {} }),
42+
});
43+
} catch {
44+
// 埋点失败不影响用户操作,静默丢弃
45+
}
46+
}
47+
48+
/**
49+
* 在客户端组件中使用的埋点 hook。
50+
* 返回 memoized 的 trackEvent,避免每次渲染都新建引用。
51+
*/
52+
export function useAnalytics() {
53+
const track = useCallback(
54+
(eventType: string, eventData?: Record<string, unknown>) =>
55+
trackEvent(eventType, eventData),
56+
[],
57+
);
58+
return { trackEvent: track };
59+
}

0 commit comments

Comments
 (0)