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

@@ -1,6 +1,6 @@
"use client";
import { ArrowDown, ArrowUp, BookOpen, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
import { ArrowDown, ArrowUp, BookOpen, CodeXml, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
import { useEffect, useState } from "react";
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
@@ -19,6 +19,7 @@ const BUILTIN_ICONS: Record<HeaderNavBuiltinKey, React.ReactNode> = {
home: <Home size={14} />,
leaderboard: <Trophy size={14} />,
library: <BookOpen size={14} />,
snippets: <CodeXml size={14} />,
};
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
@@ -26,6 +27,7 @@ const BUILTIN_HINTS: Record<HeaderNavBuiltinKey, string> = {
home: "站点首页 /",
leaderboard: "排行榜页(地址随伪静态风格变化)",
library: "书库页(地址随伪静态风格变化)",
snippets: "片段广场页(地址随伪静态风格变化)",
};
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
@@ -42,6 +44,7 @@ function switchesOf(items: HeaderNavItem[]): BuiltinSwitches {
home: present.has("home"),
leaderboard: present.has("leaderboard"),
library: present.has("library"),
snippets: present.has("snippets"),
};
}

View File

@@ -0,0 +1,277 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { CodeXml, Copy, Eye, EyeOff, GitFork, Loader2, MessagesSquare, Trash2 } from "lucide-react";
import {
AdminEmpty,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminSegmented,
} from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import Avatar from "@/components/Avatar";
import {
apiAdminDeleteSnippet,
apiAdminSetSnippetVisibility,
apiAdminSnippets,
type SnippetListItem,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import { snippetPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
type VisibilityFilter = "all" | "public" | "private";
const PAGE_SIZE = 20;
/** 片段管理:全量列表(含私有),支持搜索、可见性筛选、下架/上架与删除。 */
export default function SnippetsAdmin() {
const urlStyle = useUrlStyle();
const [items, setItems] = useState<SnippetListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [vis, setVis] = useState<VisibilityFilter>("all");
const [keyword, setKeyword] = useState("");
const [q, setQ] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [confirm, setConfirm] = useState<
| { kind: "delete"; item: SnippetListItem }
| { kind: "hide" | "show"; item: SnippetListItem }
| null
>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async (p: number, v: VisibilityFilter, kw: string) => {
setLoading(true);
setError("");
try {
const res = await apiAdminSnippets({
q: kw || undefined,
visibility: v === "all" ? undefined : v,
page: p,
size: PAGE_SIZE,
});
setItems(res.items);
setTotal(res.total);
setPage(p);
} catch (e) {
setError(e instanceof Error ? e.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(1, "all", "");
}, [load]);
const runConfirm = async () => {
if (!confirm) return;
setBusy(true);
try {
if (confirm.kind === "delete") {
await apiAdminDeleteSnippet(confirm.item.id);
toast("片段已删除", "ok");
} else {
const next = confirm.kind === "hide" ? "private" : "public";
await apiAdminSetSnippetVisibility(confirm.item.id, next);
toast(next === "private" ? "已下架为私有" : "已恢复为公开", "ok");
}
setConfirm(null);
await load(page, vis, q);
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setBusy(false);
}
};
return (
<div>
<AdminPageHeader
icon={CodeXml}
title="片段管理"
description="查看全站代码片段(含私有),可下架违规公开片段或删除。"
/>
<div
className="rounded-2xl overflow-hidden"
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
>
<div
className="flex items-center justify-between gap-2 flex-wrap px-4 sm:px-5 py-3"
style={{ borderBottom: "1px solid var(--line)" }}
>
<AdminSegmented<VisibilityFilter>
ariaLabel="可见性筛选"
size="sm"
value={vis}
onChange={(v) => void load(1, v, q)}
options={[
{ key: "all", label: "全部" },
{ key: "public", label: "公开" },
{ key: "private", label: "私有" },
]}
/>
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={() => {
setQ(keyword.trim());
void load(1, vis, keyword.trim());
}}
onClear={() => {
setKeyword("");
setQ("");
void load(1, vis, "");
}}
placeholder="搜索标题或描述…"
ariaLabel="搜索片段"
/>
</div>
{error ? <p className="alert-error m-3 text-[12.5px]">{error}</p> : null}
{loading && items.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : items.length === 0 ? (
<AdminEmpty
icon={<CodeXml size={20} />}
title="暂无片段"
description={q ? "没有匹配搜索条件的片段。" : "还没有用户发布代码片段。"}
/>
) : (
<ul className="divide-y" style={{ borderColor: "var(--line)" }}>
{items.map((it) => (
<li key={it.id} className="px-4 sm:px-5 py-3 flex items-center gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<Link
href={snippetPath(urlStyle, it.id)}
target="_blank"
className="truncate text-[14px] font-semibold hover:underline"
style={{ color: "var(--ink)" }}
>
{it.title}
</Link>
<span
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-semibold"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
{it.language}
</span>
{it.visibility === "private" ? (
<span
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-px text-[10.5px]"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<EyeOff size={10} /> 私有
</span>
) : (
<span
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-px text-[10.5px]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Eye size={10} /> 公开
</span>
)}
</div>
<div className="mt-1 flex items-center gap-2.5 text-[11.5px] flex-wrap" style={{ color: "var(--ink-3)" }}>
{it.user ? (
<span className="inline-flex items-center gap-1">
<Avatar name={it.user.nickname} src={it.user.avatar} size={14} />
{it.user.nickname}
</span>
) : (
<span>用户 #{it.user_id}</span>
)}
<span className="inline-flex items-center gap-1">
<Copy size={11} /> {it.copy_count}
</span>
<span className="inline-flex items-center gap-1">
<Eye size={11} /> {it.view_count}
</span>
<span className="inline-flex items-center gap-1">
<MessagesSquare size={11} /> {it.comment_count}
</span>
<span className="inline-flex items-center gap-1">
<GitFork size={11} /> {it.fork_count}
</span>
<span>{formatRelative(it.created_at)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{it.visibility === "public" ? (
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1"
onClick={() => setConfirm({ kind: "hide", item: it })}
>
<EyeOff size={13} /> 下架
</button>
) : (
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1"
onClick={() => setConfirm({ kind: "show", item: it })}
>
<Eye size={13} /> 上架
</button>
)}
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1"
style={{ color: "var(--danger)" }}
onClick={() => setConfirm({ kind: "delete", item: it })}
>
<Trash2 size={13} /> 删除
</button>
</div>
</li>
))}
</ul>
)}
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={(p) => void load(p, vis, q)}
/>
</div>
<ConfirmDialog
open={!!confirm}
title={
confirm?.kind === "delete"
? "删除片段"
: confirm?.kind === "hide"
? "下架片段"
: "上架片段"
}
message={
confirm?.kind === "delete"
? `确定删除片段「${confirm.item.title}」吗?删除后前台不可见。`
: confirm?.kind === "hide"
? `确定将「${confirm?.item.title}」下架为私有吗?仅作者本人可见。`
: `确定将「${confirm?.item.title ?? ""}」恢复为公开吗?`
}
danger={confirm?.kind !== "show"}
busy={busy}
confirmLabel={
confirm?.kind === "delete" ? "确认删除" : confirm?.kind === "hide" ? "确认下架" : "确认上架"
}
onCancel={() => !busy && setConfirm(null)}
onConfirm={() => void runConfirm()}
/>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isStaff } from "@/lib/roles";
import Forbidden from "../Forbidden";
import SnippetsAdmin from "./SnippetsAdmin";
export const metadata: Metadata = {
title: "片段管理",
};
export default async function AdminSnippetsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isStaff(me.user.role)) {
return <Forbidden text="片段管理仅管理员及以上角色可操作。" />;
}
return <SnippetsAdmin />;
}