Files
jiang13-bbs/frontend/app/admin/pages/PagesAdmin.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

324 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, apiAdminPurgeSitePage, 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 && !p.deleted).length,
[list]
);
const draftCount = useMemo(
() => list.filter((p) => !p.published && !p.deleted).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 {
if (p.deleted) {
// 已软删的页面:彻底删除(不可恢复,释放 slug)
await apiAdminPurgeSitePage(p.id);
setList((l) => l.filter((x) => x.id !== p.id));
toast("页面已彻底删除,地址可重新使用", "ok");
} else {
await apiAdminDeleteSitePage(p.id);
setList((l) => l.map((x) => (x.id === p.id ? { ...x, deleted: true } : x)));
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} style={p.deleted ? { opacity: 0.65 } : undefined}>
<td data-label="页面">
{p.deleted ? (
<span className="admin-doc-title" title={p.title}>
{p.title}
</span>
) : (
<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="状态">
{p.deleted ? (
<AdminStatusChip tone="danger">已删除</AdminStatusChip>
) : (
<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">
{!p.deleted ? (
<Link href={editHref(p.id, statusFilter)} className="admin-doc-textbtn">
编辑
</Link>
) : null}
{p.published && !p.deleted ? (
<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);
}}
>
{p.deleted ? "彻底删除" : "删除"}
</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={deleteTarget?.deleted ? "彻底删除单页" : "删除单页"}
message={
deleteTarget
? deleteTarget.deleted
? `确定彻底删除「${deleteTarget.title}」吗?删除后不可恢复,地址将释放可重新使用。`
: `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,可在列表中彻底删除以释放地址。`
: ""
}
danger
busy={deletingId != null}
confirmLabel={deleteTarget?.deleted ? "彻底删除" : "删除"}
onCancel={() => deletingId == null && setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) void remove(deleteTarget);
}}
/>
</div>
);
}