Files
jiang13-bbs/frontend/app/admin/announcements/AnnouncementAdmin.tsx
freefire 803b694659 后台单页和公告改为正文优先编辑,保存与发布分开。
列表按列对照真实路径和状态,避免把 /p/ 前缀和发布操作混在一起。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 01:07:21 +08:00

338 lines
13 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Ellipsis, ExternalLink, Loader2, Megaphone, Plus } from "lucide-react";
import {
apiAdminDeleteAnnouncement,
apiAdminListAnnouncements,
apiAdminToggleAnnouncementPin,
type Announcement,
} from "@/lib/api";
import { formatDateTime, formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminPageHeader,
AdminSegmented,
AdminStatusChip,
} from "@/components/admin";
import { announcementPublicPath } from "./announcementColors";
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/announcements" : `/admin/announcements?status=${status}`;
}
function newHref(status: StatusFilter): string {
return status === "all" ? "/admin/announcements/new" : `/admin/announcements/new?status=${status}`;
}
function editHref(id: number, status: StatusFilter): string {
const base = `/admin/announcements/${id}/edit`;
return status === "all" ? base : `${base}?status=${status}`;
}
/** 粗略去掉 Markdown,取一行摘要。只用于列表预览,不改库存正文。 */
function plainSummary(md: string, max = 80): string {
const text = md
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#>*_`~\-|=]/g, "")
.replace(/\s+/g, " ")
.trim();
return text.length > max ? `${text.slice(0, max)}…` : text;
}
export default function AnnouncementAdmin({
initial,
initError,
}: {
initial: Announcement[];
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 [pinningId, setPinningId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
const [menuId, setMenuId] = useState<number | null>(null);
const [menuUp, setMenuUp] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const publishedCount = useMemo(() => list.filter((a) => a.published).length, [list]);
const draftCount = useMemo(() => list.filter((a) => !a.published).length, [list]);
const filtered = useMemo(() => {
if (statusFilter === "published") return list.filter((a) => a.published);
if (statusFilter === "draft") return list.filter((a) => !a.published);
return list;
}, [list, statusFilter]);
const load = useCallback(async () => {
try {
const res = await apiAdminListAnnouncements();
setList(res.announcements);
setLoadError("");
} catch (e) {
setLoadError(e instanceof Error ? e.message : "加载公告失败");
}
}, []);
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 togglePin(a: Announcement) {
if (pinningId != null) return;
setPinningId(a.id);
try {
await apiAdminToggleAnnouncementPin(a.id);
toast(a.pinned ? "已取消置顶" : "已置顶", "ok");
await load();
} catch (e) {
toast(e instanceof Error ? e.message : "置顶失败");
} finally {
setPinningId(null);
}
}
async function remove(a: Announcement) {
setDeletingId(a.id);
try {
await apiAdminDeleteAnnouncement(a.id);
setList((l) => l.filter((x) => x.id !== a.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={Megaphone}
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={<Megaphone 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>
</tr>
</thead>
<tbody>
{filtered.map((a) => {
const summary = plainSummary(a.content);
const updated = a.updated_at || "";
return (
<tr key={a.id}>
<td data-label="公告">
<Link href={editHref(a.id, statusFilter)} className="admin-doc-title" title={a.title}>
{a.title}
</Link>
<p className="admin-doc-path">
<span className={`j13-anno-tag is-${a.tag_color}`} title={a.tag}>
{a.tag}
</span>
{a.published ? <code>{announcementPublicPath(a.id)}</code> : null}
</p>
{summary ? <p className="admin-doc-excerpt" title={summary}>{summary}</p> : null}
</td>
<td data-label="状态">
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
{a.published ? "已发布" : "草稿"}
</AdminStatusChip>
</td>
<td data-label="置顶">{a.pinned ? "置顶" : "未置顶"}</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(a.id, statusFilter)} className="admin-doc-textbtn">
编辑
</Link>
{a.published ? (
<a
href={announcementPublicPath(a.id)}
target="_blank"
rel="noopener noreferrer"
className="admin-doc-textbtn"
>
<ExternalLink size={14} aria-hidden />
查看
</a>
) : null}
<button
type="button"
className="admin-doc-textbtn"
disabled={pinningId === a.id}
onClick={() => void togglePin(a)}
>
{pinningId === a.id ? (
<Loader2 size={14} className="animate-spin" aria-hidden />
) : null}
{a.pinned ? "取消置顶" : "置顶"}
</button>
<div className="admin-doc-more" ref={menuId === a.id ? menuRef : undefined}>
<button
type="button"
className="admin-doc-textbtn"
aria-haspopup="menu"
aria-expanded={menuId === a.id}
aria-label={`「${a.title}」的更多操作`}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setMenuUp(rect.bottom > window.innerHeight - 140);
setMenuId((cur) => (cur === a.id ? null : a.id));
}}
>
<Ellipsis size={16} aria-hidden />
更多
</button>
{menuId === a.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 === a.id}
onClick={() => {
setMenuId(null);
setDeleteTarget(a);
}}
>
删除
</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>
);
}