-
Notifications
You must be signed in to change notification settings - Fork 45
feat(docs): 文档页底部显示最近 5 次更新(GitHub API) #279
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eb4edfe
890b3a7
7f7b4cf
b9f6421
206964c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`; | ||
|
|
||
|
Comment on lines
+80
to
+81
|
||
| 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 }, | ||
| ); | ||
| } | ||
|
Comment on lines
+101
to
+120
|
||
|
|
||
| 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/<name>.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", | ||
| }, | ||
| }, | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 透明占位图,避免 <Image> 收到空 src 报错 | ||
| const FALLBACK_AVATAR = | ||
| "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23e5e7eb'/></svg>"; | ||
|
|
||
| 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 ( | ||
| <div className="flex items-center gap-3 py-2.5 animate-pulse"> | ||
| <div className="w-6 h-6 rounded-full bg-neutral-200 dark:bg-neutral-700 shrink-0" /> | ||
| <div className="flex-1 flex flex-col gap-1"> | ||
| <div className="h-3 w-2/3 rounded bg-neutral-200 dark:bg-neutral-700" /> | ||
| <div className="h-2.5 w-1/3 rounded bg-neutral-100 dark:bg-neutral-800" /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| 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 ( | ||
| <div className="font-serif"> | ||
| {/* 报纸风格标题 */} | ||
| <h2 className="text-xs font-mono uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3 border-b border-neutral-200 dark:border-neutral-700 pb-2"> | ||
| 最近更新 | ||
| </h2> | ||
|
|
||
| {/* 加载中 */} | ||
| {state.status === "loading" && ( | ||
| <div className="divide-y divide-neutral-100 dark:divide-neutral-800"> | ||
| <SkeletonRow /> | ||
| <SkeletonRow /> | ||
| <SkeletonRow /> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* 错误 */} | ||
| {state.status === "error" && ( | ||
| <p className="text-xs font-mono text-neutral-400 dark:text-neutral-500 py-2"> | ||
| {state.message} | ||
| </p> | ||
| )} | ||
|
|
||
| {/* 空结果 */} | ||
| {state.status === "ok" && state.items.length === 0 && ( | ||
| <p className="text-xs font-mono text-neutral-400 dark:text-neutral-500 py-2"> | ||
| 暂无更新记录 | ||
| </p> | ||
| )} | ||
|
|
||
| {/* 历史列表 */} | ||
| {state.status === "ok" && state.items.length > 0 && ( | ||
| <ol className="divide-y divide-neutral-100 dark:divide-neutral-800"> | ||
| {state.items.map((item) => ( | ||
| <li key={item.sha}> | ||
| <a | ||
| href={item.htmlUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="flex items-start gap-3 py-2.5 group hover:bg-neutral-50 dark:hover:bg-neutral-900 rounded transition-colors px-1 -mx-1" | ||
| > | ||
| {/* 头像 */} | ||
| <Image | ||
| src={item.avatarUrl || FALLBACK_AVATAR} | ||
| alt={item.authorLogin} | ||
| width={24} | ||
| height={24} | ||
| className="rounded-full mt-0.5 shrink-0" | ||
| unoptimized | ||
| /> | ||
|
|
||
| <div className="flex-1 min-w-0"> | ||
| {/* commit message,截断超长内容 */} | ||
| <p className="text-sm leading-snug text-neutral-800 dark:text-neutral-200 truncate group-hover:text-[#CC0000] transition-colors"> | ||
| {item.message} | ||
| </p> | ||
| {/* 作者 + 时间,monospace 风格 */} | ||
| <p className="text-[11px] font-mono text-neutral-400 dark:text-neutral-500 mt-0.5"> | ||
| {item.authorName} | ||
| <span className="mx-1 opacity-40">·</span> | ||
| {relativeTime(item.date)} | ||
| </p> | ||
| </div> | ||
| </a> | ||
| </li> | ||
| ))} | ||
| </ol> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
path直接来自 query string,目前只做了“是否为空”的校验就用于拼接 GitHub API 请求。建议至少限制为文档目录内的相对路径(例如只允许以app/docs/或 docsBase 开头,拒绝包含..、反斜杠等),避免用服务端的 GitHub token 被动暴露任意仓库文件的 commit 信息。