feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
277
frontend/app/admin/snippets/SnippetsAdmin.tsx
Normal file
277
frontend/app/admin/snippets/SnippetsAdmin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/app/admin/snippets/page.tsx
Normal file
20
frontend/app/admin/snippets/page.tsx
Normal 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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user