Files
jiang13-bbs/frontend/app/admin/pages/PagesAdmin.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

297 lines
11 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
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 { useUrlStyle } from "@/components/SiteBrandProvider";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminPageHeader,
AdminSegmented,
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,
}: {
initial: SitePage[];
initError: string;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const urlStyle = useUrlStyle();
const statusFilter = readStatus(searchParams.get("status"));
const [list, setList] = useState(initial);
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]);
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]);
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 {
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);
setMenuId(null);
}
}
return (
<div className="admin-doc-page admin-doc-list">
<AdminPageHeader
icon={FileText}
title="单页管理"
description="关于本站、功能介绍等自定义页面。已发布且开启侧栏的,会出现在首页右栏。"
actions={
<Link href={newHref(statusFilter)} className="btn btn-primary">
<Plus size={15} /> 新建单页
</Link>
}
/>
<div className="mb-4">
<AdminSegmented<StatusFilter>
ariaLabel="单页状态筛选"
size="sm"
value={statusFilter}
onChange={setStatus}
options={[
{ key: "all", label: "全部", count: list.length },
{ key: "published", label: "已发布", count: publishedCount },
{ key: "draft", label: "草稿", count: draftCount },
]}
/>
</div>
<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)" }}
>
<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 ? "创建关于本站等内容页" : "试试切换筛选条件"}
/>
) : (
<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, urlStyle);
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={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}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,后台也不能恢复。`
: ""
}
danger
busy={deletingId != null}
confirmLabel="删除"
onCancel={() => deletingId == null && setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) void remove(deleteTarget);
}}
/>
</div>
);
}