diff --git a/app/components/DocsPageViewTracker.tsx b/app/components/DocsPageViewTracker.tsx new file mode 100644 index 00000000..32c9b119 --- /dev/null +++ b/app/components/DocsPageViewTracker.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; + +/** + * 文档页面访问埋点组件。 + * + * 放在 app/docs/layout.tsx 下,pathname 变化时向自家 /api/analytics 上报一次 page_view, + * 供将来基于 AnalyticsEvent 表做文档热度分析(当前 A-2 功能的热榜是用 GA4 数据,此处并行积累自家数据)。 + * + * 去重策略:同一浏览器会话内同一 path 只报一次(sessionStorage key = "pv_reported:")。 + * 为什么用 sessionStorage 不用 localStorage:关闭标签页后应当算新会话,否则长期复访的用户会被严重低估。 + * + * 无返回 UI(return null),仅作副作用组件使用。 + */ +export function DocsPageViewTracker() { + const pathname = usePathname(); + + useEffect(() => { + if (!pathname) return; + + // 同会话同 path 已上报则跳过,避免刷新/快速切换重复计数。 + // sessionStorage / localStorage 在 Safari 隐私模式、存储禁用、配额超限时会抛错, + // 埋点组件要绝对静默,全部包 try/catch 后降级到"继续上报但不去重"即可。 + const key = `pv_reported:${pathname}`; + try { + if (sessionStorage.getItem(key)) return; + sessionStorage.setItem(key, "1"); + } catch { + // storage 不可用,跳过去重继续上报 + } + + // 如果用户登录了,带上 Sa-Token 让后端能把事件关联到 userId;匿名用户后端会写入 userId=null + let token: string | null = null; + if (typeof window !== "undefined") { + try { + token = localStorage.getItem("satoken"); + } catch { + token = null; + } + } + 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(() => {}); + }, [pathname]); + + return null; +} diff --git a/app/components/rank/HotDocsTab.tsx b/app/components/rank/HotDocsTab.tsx new file mode 100644 index 00000000..d55485b3 --- /dev/null +++ b/app/components/rank/HotDocsTab.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useReducer, useEffect } from "react"; +import Link from "next/link"; + +type HotDoc = { + path: string; + title?: string; + views: number; +}; + +type WindowParam = "7d" | "30d" | "all"; + +type State = + | { status: "loading" } + | { status: "error" } + | { status: "ok"; docs: HotDoc[] }; + +type Action = + | { type: "fetch" } + | { type: "ok"; docs: HotDoc[] } + | { type: "error" }; + +function reducer(_: State, action: Action): State { + if (action.type === "fetch") return { status: "loading" }; + if (action.type === "ok") return { status: "ok", docs: action.docs }; + return { status: "error" }; +} + +// 默认走 Next.js rewrite 同源代理(见 next.config.mjs 的 /analytics/:path*), +// 若需要跨域直连后端(比如本地 Next.js 未启动但要用 curl/别的客户端测接口), +// 可设置 NEXT_PUBLIC_BACKEND_URL=http://localhost:8081 覆盖。 +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? ""; + +export function HotDocsTab({ initialWindow }: { initialWindow: WindowParam }) { + const [windowParam, setWindowParam] = useReducer( + (_: WindowParam, next: WindowParam) => next, + initialWindow, + ); + const [state, dispatch] = useReducer(reducer, { status: "loading" }); + + useEffect(() => { + dispatch({ type: "fetch" }); + let cancelled = false; + fetch(`${BACKEND_URL}/analytics/top-docs?window=${windowParam}&limit=20`) + .then((r) => { + if (!r.ok) throw new Error(); + return r.json() as Promise<{ + success: boolean; + data: HotDoc[]; + }>; + }) + .then((body) => { + if (!body.success) throw new Error(); + if (!cancelled) dispatch({ type: "ok", docs: body.data ?? [] }); + }) + .catch(() => { + if (!cancelled) dispatch({ type: "error" }); + }); + return () => { + cancelled = true; + }; + }, [windowParam]); + + const handleWindowChange = (w: WindowParam) => { + setWindowParam(w); + const url = new URL(globalThis.location.href); + url.searchParams.set("window", w); + globalThis.history.replaceState(null, "", url.toString()); + }; + + const windowOptions: { label: string; value: WindowParam }[] = [ + { label: "7D", value: "7d" }, + { label: "30D", value: "30d" }, + { label: "ALL TIME", value: "all" }, + ]; + + return ( +
+ {/* 窗口切换 */} +
+ {windowOptions.map((opt) => ( + + ))} +
+ + {/* 加载状态 */} + {state.status === "loading" && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ )} + + {/* 错误状态 */} + {state.status === "error" && ( +
+

+ 加载失败,请稍后重试 +

+
+ )} + + {/* 空状态 */} + {state.status === "ok" && state.docs.length === 0 && ( +
+

+ 数据积累中… +

+
+ )} + + {/* 列表 */} + {state.status === "ok" && state.docs.length > 0 && ( +
+ {state.docs.map((doc, idx) => ( + +
+ #{idx + 1} +
+
+
+ {doc.title || doc.path} +
+
+ {doc.path} +
+
+
+
+ {doc.views.toLocaleString()} +
+
+ VIEWS +
+
+ + ))} +
+ )} +
+ ); +} diff --git a/app/components/rank/RankTabs.tsx b/app/components/rank/RankTabs.tsx new file mode 100644 index 00000000..cb24bf23 --- /dev/null +++ b/app/components/rank/RankTabs.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { HotDocsTab } from "./HotDocsTab"; + +type Tab = "contributors" | "hot"; +type Window = "7d" | "30d" | "all"; + +// 合法取值白名单,用来校验 URL query 的任意字符串 +const VALID_TABS: readonly Tab[] = ["contributors", "hot"] as const; +const VALID_WINDOWS: readonly Window[] = ["7d", "30d", "all"] as const; + +function isValidTab(value: string | null): value is Tab { + return value !== null && (VALID_TABS as readonly string[]).includes(value); +} + +function isValidWindow(value: string | null): value is Window { + return value !== null && (VALID_WINDOWS as readonly string[]).includes(value); +} + +interface RankTabsProps { + /** Contributors tab 的静态内容,由 /rank/page.tsx SSR 渲染后以 children 传入 */ + children: React.ReactNode; + /** SSR 决定的初始 tab,来自 URL query ?tab=;客户端挂载后以 searchParams 为准 */ + initialTab: Tab; + /** SSR 决定的初始窗口,Hot Docs tab 用 */ + initialWindow: Window; +} + +/** + * /rank 页的 Tab 壳子:Contributors(贡献者榜,静态 JSON)/ Hot Docs(热门文档榜,后端 API)。 + * + * Tab 和窗口状态都写进 URL query(?tab=&window=),而不是组件内 state,这样: + * 1. 分享链接能直接定位到具体视图 + * 2. 浏览器前进/后退正常切换 + * 3. 刷新不丢状态 + * + * 用 router.push 而非 replaceState 是为了让返回键能回到上一个 tab;窗口切换在 HotDocsTab 内部用 + * replaceState,避免每切一次就污染历史栈。 + */ +export function RankTabs({ + children, + initialTab, + initialWindow, +}: RankTabsProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + // 校验 query 值是否在白名单里,非法值(例如 ?tab=foo、?window=1d)回退到 initial* + // 防止下游 HotDocsTab 收到不支持的 window,或 tab 所有分支都不命中导致空白渲染 + const rawTab = searchParams.get("tab"); + const rawWindow = searchParams.get("window"); + const activeTab: Tab = isValidTab(rawTab) ? rawTab : initialTab; + const activeWindow: Window = isValidWindow(rawWindow) + ? rawWindow + : initialWindow; + + const switchTab = (tab: Tab) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", tab); + // 首次切到 Hot Docs 还没选过窗口时默认 30d,避免 HotDocsTab 拿到 undefined + if (tab === "hot" && !params.get("window")) { + params.set("window", "30d"); + } + router.push(`?${params.toString()}`, { scroll: false }); + }; + + return ( +
+ {/* Tab 切换 */} +
+ {( + [ + { value: "contributors", label: "Contributors" }, + { value: "hot", label: "Hot Docs" }, + ] as { value: Tab; label: string }[] + ).map((tab) => ( + + ))} +
+ + {/* Tab 内容 */} + {activeTab === "contributors" &&
{children}
} + {activeTab === "hot" && } +
+ ); +} diff --git a/app/docs/layout.tsx b/app/docs/layout.tsx index 404848db..51fb33d7 100644 --- a/app/docs/layout.tsx +++ b/app/docs/layout.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { DocsRouteFlag } from "@/app/components/RouteFlags"; import type { PageTree } from "fumadocs-core/server"; import { CopyTracking } from "@/app/components/CopyTracking"; +import { DocsPageViewTracker } from "@/app/components/DocsPageViewTracker"; function pruneEmptyFolders(root: PageTree.Root): PageTree.Root { const transformNode = (node: PageTree.Node): PageTree.Node | null => { @@ -71,6 +72,7 @@ export default async function Layout({ children }: { children: ReactNode }) { {/* Add a class on while in docs to adjust global backgrounds */} + !MAINTAINERS.includes(user.name)); -export default function RankPage() { +interface PageProps { + searchParams: Promise<{ tab?: string; window?: string }>; +} + +export default async function RankPage({ searchParams }: PageProps) { + const { tab, window: win } = await searchParams; const maxPoints = mockRanks.length > 0 ? mockRanks[0].points : 100; + const initialTab = tab === "hot" ? "hot" : "contributors"; + const initialWindow = + win === "7d" || win === "all" ? (win as "7d" | "all") : "30d"; + return ( <>
@@ -31,20 +41,24 @@ export default function RankPage() { Leaderboard

- The Hall of Fame — Top Contributors + The Hall of Fame — Top Contributors & Hot Docs

-
- {mockRanks.map((user, idx) => ( - - ))} -
+ + +
+ {mockRanks.map((user, idx) => ( + + ))} +
+
+