feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
111
frontend/app/snippets/[id]/edit/page.tsx
Normal file
111
frontend/app/snippets/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiMe, apiGetSnippet } from "@/lib/api";
|
||||
import SnippetEditor from "@/components/SnippetEditor";
|
||||
|
||||
function EditSnippetInner({ id }: { id: number }) {
|
||||
const router = useRouter();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [snippet, setSnippet] = useState<{
|
||||
title: string;
|
||||
language: string;
|
||||
description: string;
|
||||
code: string;
|
||||
visibility: "public" | "private";
|
||||
user_id: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const me = await apiMe().catch(() => null);
|
||||
if (cancelled) return;
|
||||
if (!me?.user) {
|
||||
router.push(`/login?redirect=/snippets/${id}/edit`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await apiGetSnippet(id);
|
||||
if (cancelled) return;
|
||||
if (!data) {
|
||||
router.push("/snippets");
|
||||
return;
|
||||
}
|
||||
// 仅 owner 或 staff 可编辑(后端会再次校验)
|
||||
setSnippet({
|
||||
title: data.snippet.title,
|
||||
language: data.snippet.language,
|
||||
description: data.snippet.description,
|
||||
code: data.snippet.code,
|
||||
visibility: data.snippet.visibility,
|
||||
user_id: data.snippet.user_id,
|
||||
});
|
||||
setReady(true);
|
||||
} catch {
|
||||
if (!cancelled) router.push("/snippets");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router, id]);
|
||||
|
||||
if (!ready || !snippet) {
|
||||
return (
|
||||
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
|
||||
<p className="meta">正在加载片段…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SnippetEditor
|
||||
mode="edit"
|
||||
snippetId={id}
|
||||
initialTitle={snippet.title}
|
||||
initialLanguage={snippet.language}
|
||||
initialDescription={snippet.description}
|
||||
initialCode={snippet.code}
|
||||
initialVisibility={snippet.visibility}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditSnippetPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const [id, setId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
params.then((p) => {
|
||||
const numId = Number(p.id);
|
||||
if (Number.isInteger(numId) && numId > 0) setId(numId);
|
||||
});
|
||||
}, [params]);
|
||||
|
||||
if (id === null) {
|
||||
return (
|
||||
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
|
||||
<p className="meta">正在加载…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="j13-compose-page">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
|
||||
<p className="meta">正在加载片段…</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<EditSnippetInner id={id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
frontend/app/snippets/[id]/page.tsx
Normal file
253
frontend/app/snippets/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
frontend/app/snippets/new/page.tsx
Normal file
52
frontend/app/snippets/new/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiMe } from "@/lib/api";
|
||||
import SnippetEditor from "@/components/SnippetEditor";
|
||||
|
||||
function NewSnippetInner() {
|
||||
const router = useRouter();
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiMe()
|
||||
.then((res) => {
|
||||
if (!res.user) {
|
||||
router.push("/login?redirect=/snippets/new");
|
||||
return;
|
||||
}
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
router.push("/login?redirect=/snippets/new");
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
|
||||
<p className="meta">正在准备编辑器…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SnippetEditor mode="create" />;
|
||||
}
|
||||
|
||||
export default function NewSnippetPage() {
|
||||
return (
|
||||
<div className="j13-compose-page">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
|
||||
<p className="meta">正在准备编辑器…</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NewSnippetInner />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/app/snippets/page.tsx
Normal file
49
frontend/app/snippets/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { fetchSnippets, fetchSnippetLanguages, fetchSnippetStats, type SnippetStats } from "@/lib/api";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { snippetsPath } from "@/lib/urlStyle";
|
||||
import SnippetExplorer from "@/components/SnippetExplorer";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getPublicSettingsCached();
|
||||
return {
|
||||
title: "代码片段",
|
||||
description: settings.site_description,
|
||||
...(await requestCanonical(snippetsPath(settings.url_style))),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SnippetsPage() {
|
||||
const settings = await getPublicSettingsCached();
|
||||
|
||||
let initialItems: Awaited<ReturnType<typeof fetchSnippets>>["items"] = [];
|
||||
let initialTotal = 0;
|
||||
let languages: string[] = [];
|
||||
let stats: SnippetStats | null = null;
|
||||
try {
|
||||
const [listRes, langRes, statsRes] = await Promise.all([
|
||||
fetchSnippets({ size: 12 }),
|
||||
fetchSnippetLanguages(),
|
||||
fetchSnippetStats(),
|
||||
]);
|
||||
initialItems = listRes.items;
|
||||
initialTotal = listRes.total;
|
||||
languages = langRes.languages;
|
||||
stats = statsRes;
|
||||
} catch {
|
||||
/* 降级空态 */
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 pt-5 sm:pt-7">
|
||||
<SnippetExplorer
|
||||
initialItems={initialItems}
|
||||
initialTotal={initialTotal}
|
||||
languages={languages}
|
||||
urlStyle={settings.url_style}
|
||||
stats={stats}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user