后台单页和公告改为正文优先编辑,保存与发布分开。
列表按列对照真实路径和状态,避免把 /p/ 前缀和发布操作混在一起。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,33 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, 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 { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Ellipsis, ExternalLink, FileText, Plus } from "lucide-react";
|
||||
import { apiAdminDeleteSitePage, type SitePage } from "@/lib/api";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import { sitePagePublicPath } from "@/lib/sitePagePath";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminIconButton,
|
||||
AdminPageHeader,
|
||||
AdminSegmented,
|
||||
AdminSettingsPage,
|
||||
AdminStatusChip,
|
||||
} from "@/components/admin";
|
||||
|
||||
type StatusFilter = "all" | "published" | "draft";
|
||||
|
||||
function readStatus(raw: string | null): StatusFilter {
|
||||
if (raw === "published" || raw === "draft") return raw;
|
||||
return "all";
|
||||
}
|
||||
|
||||
function listHref(status: StatusFilter): string {
|
||||
return status === "all" ? "/admin/pages" : `/admin/pages?status=${status}`;
|
||||
}
|
||||
|
||||
function newHref(status: StatusFilter): string {
|
||||
return status === "all" ? "/admin/pages/new" : `/admin/pages/new?status=${status}`;
|
||||
}
|
||||
|
||||
function editHref(id: number, status: StatusFilter): string {
|
||||
const base = `/admin/pages/${id}/edit`;
|
||||
return status === "all" ? base : `${base}?status=${status}`;
|
||||
}
|
||||
|
||||
function placeLabel(p: SitePage): string {
|
||||
if (!p.show_in_sidebar) return "不在侧栏";
|
||||
return p.published ? "首页侧栏" : "首页侧栏(发布后)";
|
||||
}
|
||||
|
||||
export default function PagesAdmin({
|
||||
initial,
|
||||
initError,
|
||||
@@ -35,11 +48,16 @@ export default function PagesAdmin({
|
||||
initial: SitePage[];
|
||||
initError: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const statusFilter = readStatus(searchParams.get("status"));
|
||||
const [list, setList] = useState(initial);
|
||||
const [loadError, setLoadError] = useState(initError);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [loadError] = useState(initError);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SitePage | null>(null);
|
||||
const [menuId, setMenuId] = useState<number | null>(null);
|
||||
const [menuUp, setMenuUp] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const publishedCount = useMemo(() => list.filter((p) => p.published).length, [list]);
|
||||
const draftCount = useMemo(() => list.filter((p) => !p.published).length, [list]);
|
||||
@@ -50,6 +68,26 @@ export default function PagesAdmin({
|
||||
return list;
|
||||
}, [list, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuId == null) return;
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuId(null);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMenuId(null);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointer);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointer);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [menuId]);
|
||||
|
||||
function setStatus(next: StatusFilter) {
|
||||
router.replace(listHref(next), { scroll: false });
|
||||
}
|
||||
|
||||
async function remove(p: SitePage) {
|
||||
setDeletingId(p.id);
|
||||
try {
|
||||
@@ -61,17 +99,18 @@ export default function PagesAdmin({
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setDeleteTarget(null);
|
||||
setMenuId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminSettingsPage>
|
||||
<div className="admin-doc-page admin-doc-list">
|
||||
<AdminPageHeader
|
||||
icon={FileText}
|
||||
title="单页管理"
|
||||
description="关于本站、隐私政策等自定义 Markdown 单页;可展示在首页右栏"
|
||||
description="关于本站、功能介绍等自定义页面。已发布且开启侧栏的,会出现在首页右栏。"
|
||||
actions={
|
||||
<Link href="/admin/pages/new" className="btn btn-primary">
|
||||
<Link href={newHref(statusFilter)} className="btn btn-primary">
|
||||
<Plus size={15} /> 新建单页
|
||||
</Link>
|
||||
}
|
||||
@@ -82,7 +121,7 @@ export default function PagesAdmin({
|
||||
ariaLabel="单页状态筛选"
|
||||
size="sm"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
onChange={setStatus}
|
||||
options={[
|
||||
{ key: "all", label: "全部", count: list.length },
|
||||
{ key: "published", label: "已发布", count: publishedCount },
|
||||
@@ -91,7 +130,7 @@ export default function PagesAdmin({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel overflow-hidden min-w-0">
|
||||
<div className="panel min-w-0">
|
||||
<div
|
||||
className="px-4 sm:px-5 py-3 flex items-center justify-between"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
@@ -111,88 +150,137 @@ export default function PagesAdmin({
|
||||
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>
|
||||
))
|
||||
<div className="admin-doc-table-wrap">
|
||||
<table className="admin-doc-table">
|
||||
<caption className="sr-only">单页列表</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">页面</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">展示位置</th>
|
||||
<th scope="col">排序</th>
|
||||
<th scope="col">最近更新</th>
|
||||
<th scope="col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((p) => {
|
||||
const updated = p.updated_at || "";
|
||||
const path = sitePagePublicPath(p.slug);
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td data-label="页面">
|
||||
<Link href={editHref(p.id, statusFilter)} className="admin-doc-title" title={p.title}>
|
||||
{p.title}
|
||||
</Link>
|
||||
<p className="admin-doc-path">
|
||||
<code>{path}</code>
|
||||
</p>
|
||||
{p.excerpt ? <p className="admin-doc-excerpt">{p.excerpt}</p> : null}
|
||||
</td>
|
||||
<td data-label="状态">
|
||||
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
|
||||
{p.published ? "已发布" : "草稿"}
|
||||
</AdminStatusChip>
|
||||
</td>
|
||||
<td data-label="展示位置">{placeLabel(p)}</td>
|
||||
<td data-label="排序" className="tabular-nums">
|
||||
{p.sort_order}
|
||||
</td>
|
||||
<td data-label="最近更新">
|
||||
{updated ? (
|
||||
<time dateTime={updated} title={formatDateTime(updated)}>
|
||||
{formatRelative(updated)}
|
||||
</time>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td data-label="操作">
|
||||
<div className="admin-doc-row-actions">
|
||||
<Link href={editHref(p.id, statusFilter)} className="admin-doc-textbtn">
|
||||
编辑
|
||||
</Link>
|
||||
{p.published ? (
|
||||
<a
|
||||
href={path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-doc-textbtn"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden />
|
||||
查看
|
||||
</a>
|
||||
) : null}
|
||||
<div
|
||||
className="admin-doc-more"
|
||||
ref={menuId === p.id ? menuRef : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-doc-textbtn"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuId === p.id}
|
||||
aria-label={`「${p.title}」的更多操作`}
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMenuUp(rect.bottom > window.innerHeight - 140);
|
||||
setMenuId((cur) => (cur === p.id ? null : p.id));
|
||||
}}
|
||||
>
|
||||
<Ellipsis size={16} aria-hidden />
|
||||
更多
|
||||
</button>
|
||||
{menuId === p.id ? (
|
||||
<div
|
||||
className={`admin-doc-menu${menuUp ? " is-up" : ""}`}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="admin-doc-menu-item is-danger"
|
||||
disabled={deletingId === p.id}
|
||||
onClick={() => {
|
||||
setMenuId(null);
|
||||
setDeleteTarget(p);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<Link href={newHref(statusFilter)} className="btn btn-primary">
|
||||
<Plus size={15} /> 新建第一页
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{list.length >= 100 ? (
|
||||
<p className="meta text-[12px] px-4 sm:px-5 py-3">后台一次最多列出 100 条。</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="删除单页"
|
||||
message={deleteTarget ? `确定删除「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
|
||||
message={
|
||||
deleteTarget
|
||||
? `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,后台也不能恢复。`
|
||||
: ""
|
||||
}
|
||||
danger
|
||||
busy={deletingId != null}
|
||||
confirmLabel="删除"
|
||||
@@ -201,6 +289,6 @@ export default function PagesAdmin({
|
||||
if (deleteTarget) void remove(deleteTarget);
|
||||
}}
|
||||
/>
|
||||
</AdminSettingsPage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user