-
Notifications
You must be signed in to change notification settings - Fork 44
feat(analytics): 通用 trackEvent hook 与关键交互埋点 #277
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
26cacb7
e839124
28470d2
3bce9f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ReturnType<typeof setTimeout> | 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 ( | ||
| <button | ||
| type="button" | ||
| onClick={handleCopy} | ||
| className="inline-flex items-center gap-2 rounded-md px-4 h-11 text-base font-medium hover:bg-muted/80 hover:text-foreground" | ||
| aria-label="复制页面链接" | ||
| > | ||
|
Comment on lines
+38
to
+43
|
||
| <svg | ||
| aria-hidden="true" | ||
| className="h-5 w-5" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| strokeWidth={1.8} | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| > | ||
| {/* link 图标 */} | ||
| <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> | ||
| <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> | ||
| </svg> | ||
| {copied ? "已复制" : "复制链接"} | ||
| </button> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| "use client"; | ||
|
|
||
| import { useCallback } from "react"; | ||
|
|
||
| // 从 localStorage 安全读取 satoken。SSR 环境 / storage 禁用(Safari 隐私模式)均返回 null | ||
| function getStoredToken(): string | null { | ||
| if (typeof window === "undefined") return null; | ||
| try { | ||
| return localStorage.getItem("satoken"); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 向 Next.js 内置 /api/analytics 发送埋点事件。 | ||
| * 失败静默,不抛异常,不影响用户主流程。 | ||
| * | ||
| * Header 命名注意:/api/analytics 的 resolveUserId 从 `x-satoken` 读取 token(见 lib/server-auth.ts), | ||
| * 然后在内部再以 `satoken` header 转发给后端 /auth/me 验证。所以客户端 → Next 这一跳必须用 `x-satoken`, | ||
| * 否则 userId 永远解析不到,埋点记录的 uniqueUsers 会恒为 0。 | ||
| */ | ||
| export async function trackEvent( | ||
| eventType: string, | ||
| eventData?: Record<string, unknown>, | ||
| ): Promise<void> { | ||
| try { | ||
| const token = getStoredToken(); | ||
| // 用 Record<string, string> 而不是 HeadersInit(联合类型),保证可变 + 类型安全 | ||
| const headers: Record<string, string> = { | ||
| "Content-Type": "application/json", | ||
| }; | ||
| // 客户端 → Next 路由必须用 x-satoken(见上方注释) | ||
| if (token) { | ||
| headers["x-satoken"] = token; | ||
| } | ||
|
Comment on lines
+28
to
+36
|
||
|
|
||
| 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<string, unknown>) => | ||
| trackEvent(eventType, eventData), | ||
| [], | ||
| ); | ||
| return { trackEvent: track }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
setTimeout(() => setCopied(false), 2000)isn’t cleaned up on unmount, which can trigger a state update after navigation/unmount. Store the timer id (e.g. in a ref) and clear it in an effect cleanup (or guard with anisMountedref).