diff --git a/app/api/docs/history/route.ts b/app/api/docs/history/route.ts new file mode 100644 index 00000000..08ad6e30 --- /dev/null +++ b/app/api/docs/history/route.ts @@ -0,0 +1,153 @@ +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}`; + } + // 必须落在 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/DocHistoryPanel.tsx b/app/components/DocHistoryPanel.tsx new file mode 100644 index 00000000..b1a3298a --- /dev/null +++ b/app/components/DocHistoryPanel.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useEffect, useReducer } from "react"; +import Image from "next/image"; +import type { HistoryItem } from "@/app/types/docs-history"; + +// author 缺失时用 1x1 透明占位图,避免 收到空 src 报错 +const FALLBACK_AVATAR = + "data:image/svg+xml;utf8,"; + +interface DocHistoryPanelProps { + path: string; +} + +// 将 items / error / loading 合并成一个 discriminated union, +// 避免 effect 里多次同步 setState 触发 react-hooks/set-state-in-effect +// 同时天然保证三种状态互斥(不会同时出现"错误提示 + 旧列表") +type State = + | { status: "loading" } + | { status: "ok"; items: HistoryItem[] } + | { status: "error"; message: string }; + +type Action = + | { type: "fetch" } + | { type: "ok"; items: HistoryItem[] } + | { type: "error"; message: string }; + +function reducer(_: State, action: Action): State { + if (action.type === "fetch") return { status: "loading" }; + if (action.type === "ok") return { status: "ok", items: action.items }; + return { status: "error", message: action.message }; +} + +// 将 ISO 日期转为相对时间描述(中文) +function relativeTime(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "刚刚"; + if (minutes < 60) return `${minutes} 分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} 小时前`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days} 天前`; + const months = Math.floor(days / 30); + if (months < 12) return `${months} 个月前`; + return `${Math.floor(months / 12)} 年前`; +} + +// 骨架屏占位行 +function SkeletonRow() { + return ( +
+
+
+
+
+
+
+ ); +} + +export function DocHistoryPanel({ path }: DocHistoryPanelProps) { + const [state, dispatch] = useReducer(reducer, { status: "loading" }); + + useEffect(() => { + // 用 dispatch 而不是多次 setState,规避 react-hooks/set-state-in-effect lint; + // path 变化时立刻回到 loading,避免"错误提示 + 旧列表"并存 + dispatch({ type: "fetch" }); + let cancelled = false; + fetch(`/api/docs/history?path=${encodeURIComponent(path)}`) + .then((r) => r.json()) + .then((json) => { + if (cancelled) return; + if (json.success) { + dispatch({ type: "ok", items: json.data ?? [] }); + } else { + dispatch({ + type: "error", + message: json.error ?? "无法加载历史", + }); + } + }) + .catch(() => { + if (!cancelled) { + dispatch({ type: "error", message: "无法加载历史" }); + } + }); + return () => { + cancelled = true; + }; + }, [path]); + + return ( +
+ {/* 报纸风格标题 */} +

+ 最近更新 +

+ + {/* 加载中 */} + {state.status === "loading" && ( +
+ + + +
+ )} + + {/* 错误 */} + {state.status === "error" && ( +

+ {state.message} +

+ )} + + {/* 空结果 */} + {state.status === "ok" && state.items.length === 0 && ( +

+ 暂无更新记录 +

+ )} + + {/* 历史列表 */} + {state.status === "ok" && state.items.length > 0 && ( +
    + {state.items.map((item) => ( +
  1. + + {/* 头像 */} + {item.authorLogin} + +
    + {/* commit message,截断超长内容 */} +

    + {item.message} +

    + {/* 作者 + 时间,monospace 风格 */} +

    + {item.authorName} + · + {relativeTime(item.date)} +

    +
    +
    +
  2. + ))} +
+ )} +
+ ); +} diff --git a/app/docs/[...slug]/page.tsx b/app/docs/[...slug]/page.tsx index 64c62e29..f79a3e4c 100644 --- a/app/docs/[...slug]/page.tsx +++ b/app/docs/[...slug]/page.tsx @@ -14,6 +14,7 @@ import { Contributors } from "@/app/components/Contributors"; import { DocsAssistant } from "@/app/components/DocsAssistant"; import { LicenseNotice } from "@/app/components/LicenseNotice"; import { PageFeedback } from "@/app/components/PageFeedback"; +import { DocHistoryPanel } from "@/app/components/DocHistoryPanel"; // Extract clean text content from MDX - no longer used on client/page side // content fetching moved to API route for performance @@ -60,6 +61,9 @@ export default async function DocPage({ params }: Param) {
+
+ +
diff --git a/app/types/docs-history.ts b/app/types/docs-history.ts new file mode 100644 index 00000000..04110160 --- /dev/null +++ b/app/types/docs-history.ts @@ -0,0 +1,14 @@ +/** + * 文档历史面板共享类型。 + * 抽出到独立模块避免 client 组件从 Route Handler(server 文件)直接 import 类型, + * 防止未来 route 文件引入 node-only 依赖时 client bundle 踩边界问题。 + */ +export interface HistoryItem { + sha: string; + authorName: string; + authorLogin: string; + avatarUrl: string; + date: string; + message: string; + htmlUrl: string; +}