feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等

This commit is contained in:
2026-10-09 18:53:38 +08:00
parent 5598e1cbb7
commit da72843c70
64 changed files with 7047 additions and 248 deletions

View File

@@ -0,0 +1,253 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { cookies } from "next/headers";
import { GitFork, Copy, Eye, MessageSquare } from "lucide-react";
import {
fetchSnippetById,
fetchSnippetComments,
fetchSnippetForks,
} from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import { requestCanonical } from "@/lib/canonicalMeta";
import { snippetPath, userPath, snippetsPath } from "@/lib/urlStyle";
import { postViewerCookieHeader } from "@/lib/cookies";
import { formatRelative } from "@/lib/format";
import MarkdownBody from "@/components/MarkdownBody";
import Avatar from "@/components/Avatar";
import SnippetCodeView from "@/components/SnippetCodeView";
import SnippetCommentSection from "@/components/SnippetCommentSection";
import SnippetCommentCountChip from "@/components/SnippetCommentCountChip";
import SnippetActions from "@/components/SnippetActions";
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const numId = Number(id);
if (!Number.isInteger(numId) || numId <= 0) return { title: "片段不存在" };
const cookieHeader = postViewerCookieHeader(await cookies());
const data = await fetchSnippetById(numId, cookieHeader).catch(() => null);
if (!data) return { title: "片段不存在" };
const s = data.snippet;
return {
title: s.title,
description: s.description ? s.description.slice(0, 150) : `${s.language} 代码片段`,
...(await requestCanonical(snippetPath((await getPublicSettingsCached()).url_style, s.id))),
};
}
export default async function SnippetDetailPage({ params }: PageProps) {
const { id } = await params;
const numId = Number(id);
if (!Number.isInteger(numId) || numId <= 0) notFound();
const cookieHeader = postViewerCookieHeader(await cookies());
const [detailRes, commentsRes, forksRes, settings] = await Promise.all([
fetchSnippetById(numId, cookieHeader).catch(() => null),
fetchSnippetComments(numId, 1, 20, cookieHeader).catch(() => ({ items: [], total: 0 })),
fetchSnippetForks(numId, 1, 6).catch(() => ({ items: [], total: 0 })),
getPublicSettingsCached(),
]);
if (!detailRes) notFound();
const snippet = detailRes.snippet;
const urlStyle = settings.url_style;
const isOwner = false; // 客户端组件中通过 apiMe 校正
const isStaff = false;
return (
<div className="max-w-[1440px] mx-auto min-w-0">
<div className="mb-4 flex items-center gap-2 text-[12.5px] meta">
<Link
href={snippetsPath(urlStyle)}
className="hover:underline inline-flex items-center gap-1.5"
>
← 片段广场
</Link>
{snippet.forked_from && (
<>
<span style={{ color: "var(--ink-3)" }}>·</span>
<Link
href={snippetPath(urlStyle, snippet.forked_from.id)}
className="inline-flex items-center gap-1 hover:underline"
>
<GitFork size={11} /> Fork 自:{snippet.forked_from.title}
</Link>
</>
)}
</div>
<article className="panel px-4 py-6 sm:p-8">
{/* 头部 */}
<header>
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{snippet.language}
</span>
{snippet.visibility === "private" && (
<span
className="text-[10px] font-medium px-1.5 py-0.5 rounded-md"
style={{
background: "var(--panel-2)",
color: "var(--ink-3)",
border: "1px solid var(--line)",
}}
>
私有
</span>
)}
</div>
<h1
className="mt-2.5 text-[23px] sm:text-[28px] leading-tight font-bold"
style={{ color: "var(--ink)" }}
>
{snippet.title}
</h1>
{/* 作者 + 统计 */}
<div className="mt-4 flex items-center gap-x-4 gap-y-2.5 flex-wrap">
{snippet.user && (
<Link href={userPath(urlStyle, snippet.user.id)} className="flex items-center gap-2 group min-w-0">
<Avatar
size={30}
name={snippet.user.nickname || snippet.user.username}
src={snippet.user.avatar}
/>
<span className="min-w-0">
<span
className="block text-[13.5px] font-semibold leading-tight truncate group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{snippet.user.nickname || snippet.user.username}
</span>
<span className="block text-[11px] leading-tight meta truncate">
@{snippet.user.username}
</span>
</span>
</Link>
)}
<span className="meta text-[12px]">{formatRelative(snippet.created_at)}</span>
{snippet.updated_at !== snippet.created_at && (
<span className="meta text-[12px]">(编辑于 {formatRelative(snippet.updated_at)})</span>
)}
<span className="flex items-center gap-1.5 flex-wrap sm:ml-auto">
<StatChip icon={<Copy size={12} />} value={snippet.copy_count} label="复制" />
<StatChip icon={<Eye size={12} />} value={snippet.view_count} label="查看" />
<SnippetCommentCountChip
snippetId={snippet.id}
initial={snippet.comment_count}
icon={<MessageSquare size={12} />}
label="评论"
/>
<a
href={snippet.fork_count > 0 ? "#snippet-forks" : undefined}
className="inline-flex rounded-full"
style={{ cursor: snippet.fork_count > 0 ? "pointer" : "default" }}
title={snippet.fork_count > 0 ? "查看 Fork 演化" : "还没有人 Fork"}
>
<StatChip icon={<GitFork size={12} />} value={snippet.fork_count} label="Fork" />
</a>
</span>
</div>
{/* 操作 */}
<div className="mt-4 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<SnippetActions
snippet={snippet}
urlStyle={urlStyle}
isOwner={isOwner}
isStaff={isStaff}
/>
</div>
</header>
{/* 代码窗格 */}
<section className="mt-6">
<SnippetCodeView
code={snippet.code}
language={snippet.language}
snippetId={snippet.id}
defaultFold={true}
foldLines={25}
/>
</section>
{snippet.description && (
<section className="mt-7">
<h2 className="text-[13px] font-bold mb-2.5 flex items-center gap-2" style={{ color: "var(--ink)" }}>
<span
className="w-1 h-1 rounded-full"
style={{ background: "var(--accent)" }}
aria-hidden
/>
说明
</h2>
<MarkdownBody content={snippet.description} />
</section>
)}
{forksRes.total > 0 && (
<section id="snippet-forks" className="mt-7 scroll-mt-24">
<h2 className="text-[13px] font-bold mb-2.5 flex items-center gap-2" style={{ color: "var(--ink)" }}>
<span
className="w-1 h-1 rounded-full"
style={{ background: "var(--accent)" }}
aria-hidden
/>
Fork({forksRes.total})
</h2>
<div className="flex flex-col gap-1.5">
{forksRes.items.slice(0, 5).map((f) => (
<Link
key={f.id}
href={snippetPath(urlStyle, f.id)}
className="inline-flex items-center gap-2 self-start max-w-full rounded-lg px-2.5 py-1.5 text-[12.5px] transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)", border: "1px solid var(--line)" }}
>
<GitFork size={12} style={{ color: "var(--ink-3)" }} />
<span className="truncate">{f.title}</span>
</Link>
))}
</div>
</section>
)}
{/* 评论 */}
<section className="mt-8 pt-7" style={{ borderTop: "1px solid var(--line)" }}>
<SnippetCommentSection
snippetId={snippet.id}
initialItems={commentsRes.items}
initialTotal={commentsRes.total}
urlStyle={urlStyle}
/>
</section>
</article>
</div>
);
}
function StatChip({ icon, value, label }: { icon: React.ReactNode; value: number; label: string }) {
return (
<span
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full text-[11.5px] tabular-nums"
style={{ background: "var(--panel-2)", color: "var(--ink-3)", border: "1px solid var(--line)" }}
title={`${value} ${label}`}
>
<span style={{ color: "var(--ink-3)" }}>{icon}</span>
{value}
<span className="hidden sm:inline">{label}</span>
</span>
);
}