feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
205
frontend/app/admin/pages/PagesAdmin.tsx
Normal file
205
frontend/app/admin/pages/PagesAdmin.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user