diff --git a/app/api/docs/history/route.ts b/app/api/docs/history/route.ts deleted file mode 100644 index 714799a4..00000000 --- a/app/api/docs/history/route.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import type { HistoryItem } from "@/app/types/docs-history"; - -// 响应缓存 1 小时,GitHub API 每小时限额 5000 次(authenticated) -export const revalidate = 3600; - -interface GitHubCommit { - sha: string; - commit: { - author: { - name: string; - date: string; - }; - message: string; - }; - author: { - login: string; - avatar_url: string; - } | null; - html_url: string; -} - -/** - * 规范化前端传入的文档路径为仓库根相对路径(GitHub API 要求)。 - * - * 接受的输入形态: - * - `app/docs/ai/...`(仓库根相对)→ 原样返回 - * - `docs/ai/...` → 前面补 `app/` - * - `/docs/ai/...`(浏览器 URL 风格)→ 去开头斜杠再补 `app/` - * - * 拒绝:含 `..`、反斜杠、null 字节;最终不落在 `app/docs/` 下的路径一律拒绝, - * 避免用服务端 GITHUB_TOKEN 被动泄露仓库内任意文件的 commit 信息。 - */ -function normalizeDocsPath(raw: string): string | null { - if (!raw) return null; - // 路径穿越 / 反斜杠 / null 字节 直接拒 - if (raw.includes("..") || raw.includes("\\") || raw.includes("\0")) { - return null; - } - - let normalized = raw; - // URL 风格 /docs/... → docs/... - if (normalized.startsWith("/")) { - normalized = normalized.slice(1); - } - // docs/... → app/docs/... - if (normalized.startsWith("docs/")) { - normalized = `app/${normalized}`; - } - // fumadocs 的 page.file.path 返回"相对 app/docs/"路径(如 ai/xxx/index.mdx) - // 而不是仓库根。这里补上前缀,和 page.tsx 传参保持兼容。 - if (!normalized.startsWith("app/")) { - normalized = `app/docs/${normalized}`; - } - // 必须落在 app/docs/ 下才放行 - if (!normalized.startsWith("app/docs/")) { - return null; - } - return normalized; -} - -export async function GET(req: NextRequest) { - const { searchParams } = new URL(req.url); - const rawPath = searchParams.get("path"); - - const path = rawPath ? normalizeDocsPath(rawPath) : null; - if (!path) { - return NextResponse.json( - { - success: false, - error: "缺少合法的 path 参数(仅允许 app/docs/ 路径)", - }, - { status: 400 }, - ); - } - - const token = process.env.GITHUB_TOKEN; - if (!token) { - return NextResponse.json( - { success: false, error: "服务端未配置 GITHUB_TOKEN" }, - { status: 500 }, - ); - } - - const apiUrl = `https://api.github.com/repos/InvolutionHell/involutionhell/commits?path=${encodeURIComponent(path)}&per_page=5`; - - let res: Response; - try { - res = await fetch(apiUrl, { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - // Next.js fetch 缓存,与 revalidate 配合 - next: { revalidate: 3600 }, - }); - } catch { - return NextResponse.json( - { success: false, error: "无法连接 GitHub API" }, - { status: 502 }, - ); - } - - // 403 可能是限流、也可能是 token 权限不足 / 仓库不可访问;用 x-ratelimit-remaining 区分 - if (res.status === 403) { - const rateRemaining = res.headers.get("x-ratelimit-remaining"); - if (rateRemaining === "0") { - return NextResponse.json( - { success: false, error: "GitHub API 限流,请稍后重试" }, - { status: 429 }, - ); - } - return NextResponse.json( - { success: false, error: "GitHub API 403(可能 token 权限不足)" }, - { status: 403 }, - ); - } - - if (res.status === 401) { - return NextResponse.json( - { success: false, error: "GitHub token 无效或过期" }, - { status: 401 }, - ); - } - - if (!res.ok) { - return NextResponse.json( - { success: false, error: `GitHub API 返回 ${res.status}` }, - { status: 502 }, - ); - } - - const commits: GitHubCommit[] = await res.json(); - - const data: HistoryItem[] = commits.map((c) => ({ - sha: c.sha, - authorName: c.commit.author.name, - // author 为 null 时(commit 作者邮箱未关联 GitHub 账号),login 退回展示名 - authorLogin: c.author?.login ?? c.commit.author.name, - // commit.author.name 是展示名(可能含中文/空格),拼 github.com/.png 容易 404; - // 仅在有真实 author 时用其 avatar_url,否则返回空串让前端用占位资源 - avatarUrl: c.author?.avatar_url ?? "", - date: c.commit.author.date, - // 只取 commit message 第一行 - message: c.commit.message.split("\n")[0], - htmlUrl: c.html_url, - })); - - return NextResponse.json( - { success: true, data }, - { - headers: { - "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", - }, - }, - ); -} diff --git a/app/components/AuthNav.tsx b/app/components/AuthNav.tsx index 5bf3f779..20f39d00 100644 --- a/app/components/AuthNav.tsx +++ b/app/components/AuthNav.tsx @@ -17,6 +17,8 @@ export function AuthNav() { name: user.displayName, email: user.email ?? null, image: user.avatarUrl ?? null, + // 透传 githubId,让 UserMenu 渲染"我的主页"入口 + githubId: user.githubId ?? null, }} provider="github" logout={logout} diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 25f5657b..7d03d45d 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -30,24 +30,32 @@ export function Header() {
+ {/* + 导航对齐当前信息架构(2026-04 重构后): + - 去掉"首页":左上角 BrandMark 已经是回首页入口,避免冗余 + - 去掉"特点":Features 组件已经删除,旧的 /#features 锚点不存在 + - 文档 / 排行榜:站点两大主路由,提到顶 + - 社区:保留 /#community 锚点(指向首页底部 DispatchNetwork 的 GitHub/Discord/Zotero bar) + - 联系:缩写自"联系我们",/#contact 还在 Footer 里 + */} diff --git a/app/components/UserMenu.tsx b/app/components/UserMenu.tsx index 8eabd045..06370b3e 100644 --- a/app/components/UserMenu.tsx +++ b/app/components/UserMenu.tsx @@ -12,6 +12,8 @@ interface UserMenuProps { name?: string | null; email?: string | null; image?: string | null; + /** GitHub 数字 ID,用于拼个人主页 URL /u/{githubId} */ + githubId?: number | null; }; provider?: string; // 退出登录回调,由父组件传入(来自 useAuth().logout) @@ -58,6 +60,17 @@ export function UserMenu({ user, provider, logout }: UserMenuProps) { ) : null}
+ {/* 个人主页入口:只有拿到 githubId 的登录用户才显示,避免老账号 githubId 为 null 时跳 404 */} + {user.githubId != null && ( + + 我的主页 + + )} + {/* 设置入口:登录用户均可见,指向 /settings 偏好页 */} - {/* GitHub 个人主页跳转链接,带有 Umami 点击事件追踪埋点 */} - - GITHUB PROFILE - + {/* 本站个人主页 + GitHub 主页两个入口 */} +
+ + VIEW DOSSIER → + + + GITHUB + +
{/* 贡献统计数据展示 */} diff --git a/app/layout.tsx b/app/layout.tsx index 95735220..a004dd34 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -12,6 +12,7 @@ import { AuthProvider } from "@/lib/use-auth"; // import { SearchWrapper } from "@/app/components/SearchWrapper"; import { CustomSearchDialog } from "@/app/components/CustomSearchDialog"; import { cookies } from "next/headers"; +import { LocaleProvider } from "@/lib/i18n/client"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", @@ -209,25 +210,32 @@ export default async function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} >
- - - 写 light/dark 导致闪烁和状态不同步 - theme={{ enabled: false }} - search={{ - SearchDialog: CustomSearchDialog, - // 使用静态索引,兼容 next export 与本地开发 - options: { type: "static", api: searchApi }, - }} - > -
- {children} -
- -
-
-
+ {/* + LocaleProvider 把服务端读出的 locale 注入客户端 Context, + 客户端组件通过 useT() 拿到翻译函数,保持 SSR/CSR 一致, + 不在客户端重新读 cookie 避免水合抖动。 + */} + + + + 写 light/dark 导致闪烁和状态不同步 + theme={{ enabled: false }} + search={{ + SearchDialog: CustomSearchDialog, + // 使用静态索引,兼容 next export 与本地开发 + options: { type: "static", api: searchApi }, + }} + > +
+ {children} +
+ +
+
+
+
{/* 谷歌分析 */}