"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 { useUrlStyle } from "@/components/SiteBrandProvider"; 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 urlStyle = useUrlStyle(); const statusFilter = readStatus(searchParams.get("status")); const [list, setList] = useState(initial); const [loadError, setLoadError] = useState(initError); const [pinningId, setPinningId] = useState(null); const [deletingId, setDeletingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [menuId, setMenuId] = useState(null); const [menuUp, setMenuUp] = useState(false); const menuRef = useRef(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 (
新建公告 } />
ariaLabel="公告状态筛选" size="sm" value={statusFilter} onChange={setStatus} options={[ { key: "all", label: "全部", count: list.length }, { key: "published", label: "已发布", count: publishedCount }, { key: "draft", label: "草稿", count: draftCount }, ]} />
公告列表 共 {filtered.length} 条
{loadError ? (

{loadError}

) : filtered.length === 0 ? ( } title={list.length === 0 ? "还没有公告" : "该状态下暂无公告"} description={ list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件" } /> ) : (
{filtered.map((a) => { const summary = plainSummary(a.content); const updated = a.updated_at || ""; return ( ); })}
公告列表
公告 状态 置顶 最近更新 操作
{a.title}

{a.tag} {a.published ? {announcementPublicPath(a.id, urlStyle)} : null}

{summary ?

{summary}

: null}
{a.published ? "已发布" : "草稿"} {a.pinned ? "置顶" : "未置顶"} {updated ? ( ) : ( "—" )}
编辑 {a.published ? ( 查看 ) : null}
{menuId === a.id ? (
) : null}
)} {list.length === 0 && !loadError ? (
新建第一条公告
) : null} {list.length >= 100 ? (

后台一次最多列出 100 条。

) : null}
deletingId == null && setDeleteTarget(null)} onConfirm={() => { if (deleteTarget) void remove(deleteTarget); }} />
); }