278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
"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>
|
|
);
|
|
}
|