Files
jiang13-bbs/frontend/app/admin/pages/PagesAdmin.tsx
freefire a83276060f feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:52:06 +08:00

206 lines
6.9 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
Plus,
Pencil,
Trash2,
Loader2,
ExternalLink,
FileText,
} from "lucide-react";
import {
apiAdminDeleteSitePage,
type SitePage,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminSegmented,
AdminStatusChip,
} from "@/components/admin";
type StatusFilter = "all" | "published" | "draft";
export default function PagesAdmin({
initial,
initError,
}: {
initial: SitePage[];
initError: string;
}) {
const [list, setList] = useState(initial);
const [loadError, setLoadError] = useState(initError);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SitePage | null>(null);
const publishedCount = useMemo(() => list.filter((p) => p.published).length, [list]);
const draftCount = useMemo(() => list.filter((p) => !p.published).length, [list]);
const filtered = useMemo(() => {
if (statusFilter === "published") return list.filter((p) => p.published);
if (statusFilter === "draft") return list.filter((p) => !p.published);
return list;
}, [list, statusFilter]);
async function remove(p: SitePage) {
setDeletingId(p.id);
try {
await apiAdminDeleteSitePage(p.id);
setList((l) => l.filter((x) => x.id !== p.id));
toast("页面已删除", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "删除失败");
} finally {
setDeletingId(null);
setDeleteTarget(null);
}
}
return (
<div>
<AdminPageHeader
icon={FileText}
title="单页管理"
description="关于本站、隐私政策等自定义 Markdown 单页;可展示在首页右栏"
actions={
<Link href="/admin/pages/new" className="btn btn-primary">
<Plus size={15} /> 新建单页
</Link>
}
/>
<div className="mb-4">
<AdminSegmented<StatusFilter>
ariaLabel="单页状态筛选"
size="sm"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ key: "all", label: "全部", count: list.length },
{ key: "published", label: "已发布", count: publishedCount },
{ key: "draft", label: "草稿", count: draftCount },
]}
/>
</div>
<div className="panel overflow-hidden min-w-0">
<div
className="px-4 sm:px-5 py-3 flex items-center justify-between"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
单页列表
</span>
<span className="meta text-[12px] tabular-nums">共 {filtered.length} 条</span>
</div>
{loadError ? (
<p className="alert-error m-4">{loadError}</p>
) : filtered.length === 0 ? (
<AdminEmpty
icon={<FileText size={22} />}
title={list.length === 0 ? "还没有单页" : "该状态下暂无单页"}
description={list.length === 0 ? "创建关于本站等内容页" : "试试切换筛选条件"}
/>
) : (
filtered.map((p, idx) => (
<div
key={p.id}
className="px-4 sm:px-5 py-3.5"
style={{
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
}}
>
<div className="flex items-start gap-3 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<p
className="text-[14px] font-medium truncate max-w-full"
style={{ color: "var(--ink)" }}
data-tip={p.title}
>
{p.title}
</p>
<code className="meta text-[11.5px] tabular-nums">/{p.slug}</code>
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
{p.published ? "已发布" : "草稿"}
</AdminStatusChip>
{p.show_in_sidebar ? (
<AdminStatusChip tone="accent">侧栏</AdminStatusChip>
) : null}
<span className="meta text-[11px] tabular-nums shrink-0">
排序 {p.sort_order} · {formatRelative(p.updated_at || p.created_at)}
</span>
</div>
{p.excerpt ? (
<p className="meta text-[12.5px] mt-1 line-clamp-1">{p.excerpt}</p>
) : null}
</div>
<span className="inline-flex items-center gap-0.5 shrink-0">
{p.published ? (
<AdminIconButton
href={`/p/${p.slug}`}
tip="查看前台"
ariaLabel="查看单页"
>
<ExternalLink size={14} />
</AdminIconButton>
) : null}
<AdminIconButton
href={`/admin/pages/${p.id}/edit`}
tip="编辑"
ariaLabel="编辑单页"
>
<Pencil size={14} />
</AdminIconButton>
<AdminIconButton
tip="删除"
ariaLabel="删除单页"
tone="danger"
disabled={deletingId === p.id}
onClick={() => setDeleteTarget(p)}
>
{deletingId === p.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</AdminIconButton>
</span>
</div>
</div>
))
)}
{list.length === 0 && !loadError ? (
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
<Link href="/admin/pages/new" className="btn btn-primary">
<Plus size={15} /> 新建第一页
</Link>
</div>
) : null}
</div>
<ConfirmDialog
open={!!deleteTarget}
title="删除单页"
message={deleteTarget ? `确定删除「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
danger
busy={deletingId != null}
confirmLabel="删除"
onCancel={() => deletingId == null && setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) void remove(deleteTarget);
}}
/>
</div>
);
}