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
60 changes: 60 additions & 0 deletions app/components/DocsPageViewTracker.tsx
Original file line number Diff line number Diff line change
@@ -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:<path>")。
* 为什么用 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<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(() => {});
}, [pathname]);

return null;
}
161 changes: 161 additions & 0 deletions app/components/rank/HotDocsTab.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
{/* 窗口切换 */}
<div className="flex gap-0 mb-8 border border-[var(--foreground)]">
{windowOptions.map((opt) => (
<button
key={opt.value}
onClick={() => handleWindowChange(opt.value)}
className={`flex-1 py-2 font-mono text-xs uppercase tracking-widest transition-colors ${
windowParam === opt.value
? "bg-[var(--foreground)] text-[var(--background)]"
: "bg-[var(--background)] text-[var(--foreground)] hover:bg-[var(--foreground)]/10"
}`}
>
{opt.label}
</button>
))}
</div>

{/* 加载状态 */}
{state.status === "loading" && (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="border border-[var(--foreground)] p-4 animate-pulse bg-[var(--foreground)]/5 h-16"
/>
))}
</div>
)}

{/* 错误状态 */}
{state.status === "error" && (
<div className="border border-[var(--foreground)] p-8 text-center">
<p className="font-mono text-sm uppercase tracking-widest text-neutral-500">
加载失败,请稍后重试
</p>
</div>
)}

{/* 空状态 */}
{state.status === "ok" && state.docs.length === 0 && (
<div className="border border-[var(--foreground)] p-8 text-center">
<p className="font-mono text-sm uppercase tracking-widest text-neutral-500">
数据积累中…
</p>
</div>
)}

{/* 列表 */}
{state.status === "ok" && state.docs.length > 0 && (
<div className="flex flex-col gap-3">
{state.docs.map((doc, idx) => (
<Link
key={doc.path}
href={doc.path}
className="group w-full flex items-center gap-4 border border-[var(--foreground)] p-4 bg-[var(--background)] hard-shadow-hover transition-all"
>
<div className="font-mono text-2xl font-bold w-12 text-center text-[var(--foreground)] shrink-0">
#{idx + 1}
</div>
<div className="flex-1 min-w-0">
<div className="font-serif text-xl font-bold text-[var(--foreground)] truncate group-hover:underline decoration-2 decoration-[#CC0000] underline-offset-4">
{doc.title || doc.path}
</div>
<div className="font-mono text-xs uppercase text-neutral-500 mt-1 truncate">
{doc.path}
</div>
</div>
<div className="shrink-0 text-right">
<div className="font-serif font-black text-2xl text-[#CC0000]">
{doc.views.toLocaleString()}
</div>
<div className="font-mono text-[10px] uppercase tracking-widest text-neutral-500">
VIEWS
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
96 changes: 96 additions & 0 deletions app/components/rank/RankTabs.tsx
Original file line number Diff line number Diff line change
@@ -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;

Comment on lines +47 to +56
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.

useSearchParams().get() returns an arbitrary string | null, but the code casts it to Tab/Window without validation. If the URL contains an unexpected value (e.g. ?tab=foo or ?window=1d), activeTab/activeWindow become invalid at runtime, which can result in rendering no tab content and/or passing an unsupported window to HotDocsTab.

Consider explicitly whitelisting allowed values and falling back to initialTab/initialWindow (or a safe default) when the query value is not recognized.

Copilot uses AI. Check for mistakes.
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 (
<div>
{/* Tab 切换 */}
<div className="flex gap-0 mb-10 border-b-4 border-[var(--foreground)]">
{(
[
{ value: "contributors", label: "Contributors" },
{ value: "hot", label: "Hot Docs" },
] as { value: Tab; label: string }[]
).map((tab) => (
<button
key={tab.value}
onClick={() => switchTab(tab.value)}
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 ${
activeTab === tab.value
? "bg-[var(--foreground)] text-[var(--background)]"
: "bg-[var(--background)] text-[var(--foreground)] hover:bg-[var(--foreground)]/10"
}`}
>
{tab.label}
</button>
))}
</div>

{/* Tab 内容 */}
{activeTab === "contributors" && <div>{children}</div>}
{activeTab === "hot" && <HotDocsTab initialWindow={activeWindow} />}
</div>
);
}
2 changes: 2 additions & 0 deletions app/docs/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -71,6 +72,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
{/* Add a class on <html> while in docs to adjust global backgrounds */}
<CopyTracking />
<DocsRouteFlag />
<DocsPageViewTracker />
<DocsLayout
tree={tree}
{...options}
Expand Down
Loading
Loading