Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 41 additions & 29 deletions app/components/CustomSearchDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<boolean | undefined>(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"
Expand All @@ -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 });
}
Expand All @@ -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]);

Expand All @@ -142,6 +153,7 @@ export function CustomSearchDialog({
onSearchChange={setSearch}
isLoading={query.isLoading}
onOpenChange={onOpenChange}
open={open}
{...otherProps}
>
<SearchDialogOverlay />
Expand Down
61 changes: 61 additions & 0 deletions app/components/DocShareButton.tsx
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 {
Comment on lines +24 to +29
Copy link

Copilot AI Apr 14, 2026

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 an isMounted ref).

Copilot uses AI. Check for mistakes.
// 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
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The <button> doesn’t specify a type. If this component is ever rendered inside a <form>, it will default to type="submit" and may trigger unintended form submissions. Set type="button" explicitly.

Copilot uses AI. Check for mistakes.
<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>
);
}
61 changes: 23 additions & 38 deletions app/components/DocsPageViewTracker.tsx
Original file line number Diff line number Diff line change
@@ -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:<path>")。
* 为什么用 sessionStorage 不用 localStorage:关闭标签页后应当算新会话,否则长期复访的用户会被严重低估。
*
* 无返回 UI(return null),仅作副作用组件使用。
* 文档页 PV 埋点组件。
* 挂载在 docs layout 下,监听路由变化上报 page_view 事件。
* 用 sessionStorage 去重:同一 session 内同一路径只上报一次。
*/
export function DocsPageViewTracker() {
const pathname = usePathname();
// 记录上次上报的路径,避免 StrictMode 下双渲染重复发送
const lastTrackedRef = useRef<string | null>(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<string, string> = {
"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;
Expand Down
6 changes: 5 additions & 1 deletion app/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -53,7 +54,10 @@ export default async function DocPage({ params }: Param) {
<h1 className="text-3xl font-extrabold tracking-tight md:text-4xl">
{page.data.title}
</h1>
<EditOnGithub href={editUrl} />
<div className="flex items-center gap-2">
<DocShareButton />
<EditOnGithub href={editUrl} />
</div>
</div>
<Mdx components={getMDXComponents()} />
<Contributors entry={contributorsEntry} />
Expand Down
1 change: 1 addition & 0 deletions app/docs/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
<>
{/* Add a class on <html> while in docs to adjust global backgrounds */}
<CopyTracking />
<DocsPageViewTracker />
<DocsRouteFlag />
<DocsPageViewTracker />
<DocsLayout
Expand Down
59 changes: 59 additions & 0 deletions lib/analytics.ts
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
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trackEvent builds headers as HeadersInit and then mutates it via headers["..."] = .... In TS HeadersInit is a union (Headers | string[][] | Record<string,string>), so indexing/mutation is not type-safe and typically fails under strict typecheck. Use a concrete mutable type (e.g. Record<string, string> or new Headers() with .set).

Copilot uses AI. Check for mistakes.

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 };
}
Loading