From 5f5e2ba059b4e859ea7a0d47ee7255a7e90d1c60 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Tue, 14 Apr 2026 18:01:57 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E8=A1=A5=20/docs=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E8=87=AA=E5=AE=B6=20analytics=20page=5Fview=20?= =?UTF-8?q?=E5=9F=8B=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DocsPageViewTracker 客户端组件,usePathname+useEffect 监听路由 - sessionStorage 去重,同一会话同一 path 不重复上报 - POST /api/analytics,携带 x-satoken(有 token 时),匿名用户 userId 由后端解析为 null - 在 app/docs/layout.tsx 中挂载 --- app/components/DocsPageViewTracker.tsx | 35 ++++++++++++++++++++++++++ app/docs/layout.tsx | 2 ++ 2 files changed, 37 insertions(+) create mode 100644 app/components/DocsPageViewTracker.tsx diff --git a/app/components/DocsPageViewTracker.tsx b/app/components/DocsPageViewTracker.tsx new file mode 100644 index 00000000..87a2bd7b --- /dev/null +++ b/app/components/DocsPageViewTracker.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; + +export function DocsPageViewTracker() { + const pathname = usePathname(); + + useEffect(() => { + if (!pathname) return; + + const key = `pv_reported:${pathname}`; + if (sessionStorage.getItem(key)) return; + + sessionStorage.setItem(key, "1"); + + const token = + typeof window !== "undefined" ? localStorage.getItem("satoken") : 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/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 */} + Date: Tue, 14 Apr 2026 18:01:57 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20/rank=20=E9=A1=B5=E5=8A=A0=E8=B4=A1?= =?UTF-8?q?=E7=8C=AE=E8=80=85=E6=A6=9C/=E7=83=AD=E9=97=A8=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=A6=9C=20tab=20=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/components/rank/HotDocsTab.tsx | 157 +++++++++++++++++++++++++++++ app/components/rank/RankTabs.tsx | 63 ++++++++++++ app/rank/page.tsx | 40 +++++--- 3 files changed, 247 insertions(+), 13 deletions(-) create mode 100644 app/components/rank/HotDocsTab.tsx create mode 100644 app/components/rank/RankTabs.tsx diff --git a/app/components/rank/HotDocsTab.tsx b/app/components/rank/HotDocsTab.tsx new file mode 100644 index 00000000..1601d78b --- /dev/null +++ b/app/components/rank/HotDocsTab.tsx @@ -0,0 +1,157 @@ +"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" }; +} + +const BACKEND_URL = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:8080"; + +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}/api/v1/analytics/top-docs?window=${windowParam}&limit=20`, + ) + .then((r) => { + if (!r.ok) throw new Error(); + return r.json() as Promise; + }) + .then((docs) => { + if (!cancelled) dispatch({ type: "ok", docs }); + }) + .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..b24c7708 --- /dev/null +++ b/app/components/rank/RankTabs.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { HotDocsTab } from "./HotDocsTab"; + +type Tab = "contributors" | "hot"; +type Window = "7d" | "30d" | "all"; + +interface RankTabsProps { + children: React.ReactNode; + initialTab: Tab; + initialWindow: Window; +} + +export function RankTabs({ + children, + initialTab, + initialWindow, +}: RankTabsProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const activeTab = (searchParams.get("tab") as Tab) ?? initialTab; + const activeWindow = (searchParams.get("window") as Window) ?? initialWindow; + + const switchTab = (tab: Tab) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", tab); + 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/rank/page.tsx b/app/rank/page.tsx index e60cac66..8b2a3c90 100644 --- a/app/rank/page.tsx +++ b/app/rank/page.tsx @@ -1,12 +1,13 @@ import { Header } from "@/app/components/Header"; import { Footer } from "@/app/components/Footer"; import { ContributorRow } from "@/app/components/rank/ContributorRow"; +import { RankTabs } from "@/app/components/rank/RankTabs"; +import { Suspense } from "react"; import leaderboardData from "@/generated/site-leaderboard.json"; import { MAINTAINERS } from "@/lib/admins"; -// We use the generated JSON const rawRanks = leaderboardData as { id: string; name: string; @@ -18,9 +19,18 @@ const rawRanks = leaderboardData as { const mockRanks = rawRanks.filter((user) => !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) => ( + + ))} +
+
+