Skip to content

Commit a6ed407

Browse files
authored
feat(rank): /rank 页热门文档 tab + /docs 埋点 (#276)
* feat: 补 /docs 页面自家 analytics page_view 埋点 - 新增 DocsPageViewTracker 客户端组件,usePathname+useEffect 监听路由 - sessionStorage 去重,同一会话同一 path 不重复上报 - POST /api/analytics,携带 x-satoken(有 token 时),匿名用户 userId 由后端解析为 null - 在 app/docs/layout.tsx 中挂载 * feat: /rank 页加贡献者榜/热门文档榜 tab 切换 - RankTabs 客户端组件:Contributors | Hot Docs 两 tab,URL query ?tab= 保存状态 - HotDocsTab 组件:7d/30d/all time 窗口切换,fetch /api/v1/analytics/top-docs - useReducer 避免 setState 在 effect 中同步调用的 lint 报错 - 加载/错误/空状态(空时显示「数据积累中…」) - 设计语言:font-serif font-black、border-b-4、hard-shadow-hover 贴合 Newspaper 风格 - rank/page.tsx 改造为 server+client 混合,Suspense 包裹 RankTabs * fix(rank): HotDocsTab 走同源代理并解包 ApiResponse - next.config.mjs 新增 /analytics/:path* rewrite 转发至后端 BACKEND_URL - 前端改用同源 /analytics/top-docs 请求,NEXT_PUBLIC_BACKEND_URL 作为可选覆盖(默认空串走代理),避免浏览器跨域 - 解包后端 ApiResponse 取 data 字段再渲染,之前直接当 HotDoc[] 用导致列表不显示 * docs(rank): 补中文注释说明埋点组件与 tab 壳子意图 - DocsPageViewTracker 说明埋点目的、sessionStorage 去重策略与静默失败 - RankTabs 解释为什么状态走 URL query 而不是 component state * chore(rank): CR - RankTabs query 校验 + PageViewTracker storage try/catch Copilot CR #276: - RankTabs: useSearchParams 返回 string|null,之前直接 as Tab/Window 没校验, ?tab=foo 或 ?window=1d 会让下游组件拿到非法值导致空白渲染。 加白名单 + type guard,非法时 fallback 到 initial* - DocsPageViewTracker: sessionStorage / localStorage 包 try/catch Safari 隐私模式或存储禁用时不崩,降级到继续上报但不去重 * chore(rank): CR - RankTabs 加 tab/window 白名单校验 接前一 commit,补上之前落下的 RankTabs 改动:useSearchParams 返回 string|null,非法值(?tab=foo、?window=1d)之前被强转 as Tab/Window 后 导致下游 HotDocsTab 收到非法 window 或 tab 所有分支都不命中导致空白渲染。 加 type guard + fallback 到 initial*
1 parent 356cecc commit a6ed407

6 files changed

Lines changed: 350 additions & 13 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import { usePathname } from "next/navigation";
4+
import { useEffect } from "react";
5+
6+
/**
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),仅作副作用组件使用。
16+
*/
17+
export function DocsPageViewTracker() {
18+
const pathname = usePathname();
19+
20+
useEffect(() => {
21+
if (!pathname) return;
22+
23+
// 同会话同 path 已上报则跳过,避免刷新/快速切换重复计数。
24+
// sessionStorage / localStorage 在 Safari 隐私模式、存储禁用、配额超限时会抛错,
25+
// 埋点组件要绝对静默,全部包 try/catch 后降级到"继续上报但不去重"即可。
26+
const key = `pv_reported:${pathname}`;
27+
try {
28+
if (sessionStorage.getItem(key)) return;
29+
sessionStorage.setItem(key, "1");
30+
} catch {
31+
// storage 不可用,跳过去重继续上报
32+
}
33+
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+
}
42+
}
43+
const headers: Record<string, string> = {
44+
"Content-Type": "application/json",
45+
};
46+
if (token) headers["x-satoken"] = token;
47+
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(() => {});
57+
}, [pathname]);
58+
59+
return null;
60+
}

app/components/rank/HotDocsTab.tsx

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"use client";
2+
3+
import { useReducer, useEffect } from "react";
4+
import Link from "next/link";
5+
6+
type HotDoc = {
7+
path: string;
8+
title?: string;
9+
views: number;
10+
};
11+
12+
type WindowParam = "7d" | "30d" | "all";
13+
14+
type State =
15+
| { status: "loading" }
16+
| { status: "error" }
17+
| { status: "ok"; docs: HotDoc[] };
18+
19+
type Action =
20+
| { type: "fetch" }
21+
| { type: "ok"; docs: HotDoc[] }
22+
| { type: "error" };
23+
24+
function reducer(_: State, action: Action): State {
25+
if (action.type === "fetch") return { status: "loading" };
26+
if (action.type === "ok") return { status: "ok", docs: action.docs };
27+
return { status: "error" };
28+
}
29+
30+
// 默认走 Next.js rewrite 同源代理(见 next.config.mjs 的 /analytics/:path*),
31+
// 若需要跨域直连后端(比如本地 Next.js 未启动但要用 curl/别的客户端测接口),
32+
// 可设置 NEXT_PUBLIC_BACKEND_URL=http://localhost:8081 覆盖。
33+
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "";
34+
35+
export function HotDocsTab({ initialWindow }: { initialWindow: WindowParam }) {
36+
const [windowParam, setWindowParam] = useReducer(
37+
(_: WindowParam, next: WindowParam) => next,
38+
initialWindow,
39+
);
40+
const [state, dispatch] = useReducer(reducer, { status: "loading" });
41+
42+
useEffect(() => {
43+
dispatch({ type: "fetch" });
44+
let cancelled = false;
45+
fetch(`${BACKEND_URL}/analytics/top-docs?window=${windowParam}&limit=20`)
46+
.then((r) => {
47+
if (!r.ok) throw new Error();
48+
return r.json() as Promise<{
49+
success: boolean;
50+
data: HotDoc[];
51+
}>;
52+
})
53+
.then((body) => {
54+
if (!body.success) throw new Error();
55+
if (!cancelled) dispatch({ type: "ok", docs: body.data ?? [] });
56+
})
57+
.catch(() => {
58+
if (!cancelled) dispatch({ type: "error" });
59+
});
60+
return () => {
61+
cancelled = true;
62+
};
63+
}, [windowParam]);
64+
65+
const handleWindowChange = (w: WindowParam) => {
66+
setWindowParam(w);
67+
const url = new URL(globalThis.location.href);
68+
url.searchParams.set("window", w);
69+
globalThis.history.replaceState(null, "", url.toString());
70+
};
71+
72+
const windowOptions: { label: string; value: WindowParam }[] = [
73+
{ label: "7D", value: "7d" },
74+
{ label: "30D", value: "30d" },
75+
{ label: "ALL TIME", value: "all" },
76+
];
77+
78+
return (
79+
<div>
80+
{/* 窗口切换 */}
81+
<div className="flex gap-0 mb-8 border border-[var(--foreground)]">
82+
{windowOptions.map((opt) => (
83+
<button
84+
key={opt.value}
85+
onClick={() => handleWindowChange(opt.value)}
86+
className={`flex-1 py-2 font-mono text-xs uppercase tracking-widest transition-colors ${
87+
windowParam === opt.value
88+
? "bg-[var(--foreground)] text-[var(--background)]"
89+
: "bg-[var(--background)] text-[var(--foreground)] hover:bg-[var(--foreground)]/10"
90+
}`}
91+
>
92+
{opt.label}
93+
</button>
94+
))}
95+
</div>
96+
97+
{/* 加载状态 */}
98+
{state.status === "loading" && (
99+
<div className="flex flex-col gap-3">
100+
{Array.from({ length: 5 }).map((_, i) => (
101+
<div
102+
key={i}
103+
className="border border-[var(--foreground)] p-4 animate-pulse bg-[var(--foreground)]/5 h-16"
104+
/>
105+
))}
106+
</div>
107+
)}
108+
109+
{/* 错误状态 */}
110+
{state.status === "error" && (
111+
<div className="border border-[var(--foreground)] p-8 text-center">
112+
<p className="font-mono text-sm uppercase tracking-widest text-neutral-500">
113+
加载失败,请稍后重试
114+
</p>
115+
</div>
116+
)}
117+
118+
{/* 空状态 */}
119+
{state.status === "ok" && state.docs.length === 0 && (
120+
<div className="border border-[var(--foreground)] p-8 text-center">
121+
<p className="font-mono text-sm uppercase tracking-widest text-neutral-500">
122+
数据积累中…
123+
</p>
124+
</div>
125+
)}
126+
127+
{/* 列表 */}
128+
{state.status === "ok" && state.docs.length > 0 && (
129+
<div className="flex flex-col gap-3">
130+
{state.docs.map((doc, idx) => (
131+
<Link
132+
key={doc.path}
133+
href={doc.path}
134+
className="group w-full flex items-center gap-4 border border-[var(--foreground)] p-4 bg-[var(--background)] hard-shadow-hover transition-all"
135+
>
136+
<div className="font-mono text-2xl font-bold w-12 text-center text-[var(--foreground)] shrink-0">
137+
#{idx + 1}
138+
</div>
139+
<div className="flex-1 min-w-0">
140+
<div className="font-serif text-xl font-bold text-[var(--foreground)] truncate group-hover:underline decoration-2 decoration-[#CC0000] underline-offset-4">
141+
{doc.title || doc.path}
142+
</div>
143+
<div className="font-mono text-xs uppercase text-neutral-500 mt-1 truncate">
144+
{doc.path}
145+
</div>
146+
</div>
147+
<div className="shrink-0 text-right">
148+
<div className="font-serif font-black text-2xl text-[#CC0000]">
149+
{doc.views.toLocaleString()}
150+
</div>
151+
<div className="font-mono text-[10px] uppercase tracking-widest text-neutral-500">
152+
VIEWS
153+
</div>
154+
</div>
155+
</Link>
156+
))}
157+
</div>
158+
)}
159+
</div>
160+
);
161+
}

app/components/rank/RankTabs.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"use client";
2+
3+
import { useRouter, useSearchParams } from "next/navigation";
4+
import { HotDocsTab } from "./HotDocsTab";
5+
6+
type Tab = "contributors" | "hot";
7+
type Window = "7d" | "30d" | "all";
8+
9+
// 合法取值白名单,用来校验 URL query 的任意字符串
10+
const VALID_TABS: readonly Tab[] = ["contributors", "hot"] as const;
11+
const VALID_WINDOWS: readonly Window[] = ["7d", "30d", "all"] as const;
12+
13+
function isValidTab(value: string | null): value is Tab {
14+
return value !== null && (VALID_TABS as readonly string[]).includes(value);
15+
}
16+
17+
function isValidWindow(value: string | null): value is Window {
18+
return value !== null && (VALID_WINDOWS as readonly string[]).includes(value);
19+
}
20+
21+
interface RankTabsProps {
22+
/** Contributors tab 的静态内容,由 /rank/page.tsx SSR 渲染后以 children 传入 */
23+
children: React.ReactNode;
24+
/** SSR 决定的初始 tab,来自 URL query ?tab=;客户端挂载后以 searchParams 为准 */
25+
initialTab: Tab;
26+
/** SSR 决定的初始窗口,Hot Docs tab 用 */
27+
initialWindow: Window;
28+
}
29+
30+
/**
31+
* /rank 页的 Tab 壳子:Contributors(贡献者榜,静态 JSON)/ Hot Docs(热门文档榜,后端 API)。
32+
*
33+
* Tab 和窗口状态都写进 URL query(?tab=&window=),而不是组件内 state,这样:
34+
* 1. 分享链接能直接定位到具体视图
35+
* 2. 浏览器前进/后退正常切换
36+
* 3. 刷新不丢状态
37+
*
38+
* 用 router.push 而非 replaceState 是为了让返回键能回到上一个 tab;窗口切换在 HotDocsTab 内部用
39+
* replaceState,避免每切一次就污染历史栈。
40+
*/
41+
export function RankTabs({
42+
children,
43+
initialTab,
44+
initialWindow,
45+
}: RankTabsProps) {
46+
const router = useRouter();
47+
const searchParams = useSearchParams();
48+
// 校验 query 值是否在白名单里,非法值(例如 ?tab=foo、?window=1d)回退到 initial*
49+
// 防止下游 HotDocsTab 收到不支持的 window,或 tab 所有分支都不命中导致空白渲染
50+
const rawTab = searchParams.get("tab");
51+
const rawWindow = searchParams.get("window");
52+
const activeTab: Tab = isValidTab(rawTab) ? rawTab : initialTab;
53+
const activeWindow: Window = isValidWindow(rawWindow)
54+
? rawWindow
55+
: initialWindow;
56+
57+
const switchTab = (tab: Tab) => {
58+
const params = new URLSearchParams(searchParams.toString());
59+
params.set("tab", tab);
60+
// 首次切到 Hot Docs 还没选过窗口时默认 30d,避免 HotDocsTab 拿到 undefined
61+
if (tab === "hot" && !params.get("window")) {
62+
params.set("window", "30d");
63+
}
64+
router.push(`?${params.toString()}`, { scroll: false });
65+
};
66+
67+
return (
68+
<div>
69+
{/* Tab 切换 */}
70+
<div className="flex gap-0 mb-10 border-b-4 border-[var(--foreground)]">
71+
{(
72+
[
73+
{ value: "contributors", label: "Contributors" },
74+
{ value: "hot", label: "Hot Docs" },
75+
] as { value: Tab; label: string }[]
76+
).map((tab) => (
77+
<button
78+
key={tab.value}
79+
onClick={() => switchTab(tab.value)}
80+
className={`px-6 py-3 font-mono text-sm uppercase tracking-widest transition-colors border-t border-l border-r border-[var(--foreground)] -mb-1 ${
81+
activeTab === tab.value
82+
? "bg-[var(--foreground)] text-[var(--background)]"
83+
: "bg-[var(--background)] text-[var(--foreground)] hover:bg-[var(--foreground)]/10"
84+
}`}
85+
>
86+
{tab.label}
87+
</button>
88+
))}
89+
</div>
90+
91+
{/* Tab 内容 */}
92+
{activeTab === "contributors" && <div>{children}</div>}
93+
{activeTab === "hot" && <HotDocsTab initialWindow={activeWindow} />}
94+
</div>
95+
);
96+
}

app/docs/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
55
import { DocsRouteFlag } from "@/app/components/RouteFlags";
66
import type { PageTree } from "fumadocs-core/server";
77
import { CopyTracking } from "@/app/components/CopyTracking";
8+
import { DocsPageViewTracker } from "@/app/components/DocsPageViewTracker";
89

910
function pruneEmptyFolders(root: PageTree.Root): PageTree.Root {
1011
const transformNode = (node: PageTree.Node): PageTree.Node | null => {
@@ -71,6 +72,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
7172
{/* Add a class on <html> while in docs to adjust global backgrounds */}
7273
<CopyTracking />
7374
<DocsRouteFlag />
75+
<DocsPageViewTracker />
7476
<DocsLayout
7577
tree={tree}
7678
{...options}

0 commit comments

Comments
 (0)