"use client"; import { useCallback, useMemo, useState } from "react"; import { Plus, Pencil, Trash2, Loader2, Save, X, ExternalLink, Megaphone, } from "lucide-react"; import { apiAdminListAnnouncements, apiAdminCreateAnnouncement, apiAdminUpdateAnnouncement, apiAdminDeleteAnnouncement, type Announcement, type AnnouncementColor, type AnnouncementInput, } from "@/lib/api"; import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import ConfirmDialog from "@/components/ConfirmDialog"; import { AdminEmpty, AdminField, AdminIconButton, AdminPageHeader, AdminSegmented, AdminStatusChip, } from "@/components/admin"; const COLORS: { value: AnnouncementColor; label: string }[] = [ { value: "blue", label: "蓝色" }, { value: "green", label: "绿色" }, { value: "orange", label: "橙色" }, { value: "red", label: "红色" }, { value: "purple", label: "紫色" }, { value: "gray", label: "灰色" }, ]; type FormState = AnnouncementInput & { id: number | null }; type PublishKey = "published" | "draft"; type StatusFilter = "all" | "published" | "draft"; const EMPTY_FORM: FormState = { id: null, title: "", content: "", tag: "公告", tag_color: "blue", published: true, }; /** 粗略去掉 Markdown,取一行摘要 */ function plainSummary(md: string, max = 96): 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; } function AnnouncementForm({ form, setForm, saving, onSave, onCancel, }: { form: FormState; setForm: (f: FormState) => void; saving: boolean; onSave: () => void; onCancel: () => void; }) { return (

{form.id == null ? "新建公告" : "编辑公告"}

setForm({ ...form, title })} placeholder="请输入公告标题" />
setForm({ ...form, tag })} placeholder="公告" />

标签颜色

{COLORS.map((c) => (

发布状态

role="group" ariaLabel="发布状态" value={form.published ? "published" : "draft"} onChange={(key) => setForm({ ...form, published: key === "published" })} options={[ { key: "published", label: "立即发布" }, { key: "draft", label: "存为草稿" }, ]} />
setForm({ ...form, content })} placeholder="请输入公告正文,支持 Markdown 语法…" />
); } function AnnouncementPreview({ a }: { a: Announcement }) { return (
{a.tag} {a.published ? "已发布" : "草稿"} {formatRelative(a.updated_at || a.created_at)}

{a.title}

{a.content}

{a.published ? ( 查看前台页 ) : null}
); } export default function AnnouncementAdmin({ initial, initError, }: { initial: Announcement[]; initError: string; }) { const [list, setList] = useState(initial); const [loadError, setLoadError] = useState(initError); const [form, setForm] = useState(null); const [selectedId, setSelectedId] = useState(initial[0]?.id ?? null); const [statusFilter, setStatusFilter] = useState("all"); const [saving, setSaving] = useState(false); const [deletingId, setDeletingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(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 selected = useMemo( () => (selectedId == null ? null : list.find((a) => a.id === selectedId) ?? null), [list, selectedId] ); const load = useCallback(async () => { try { const res = await apiAdminListAnnouncements(); setList(res.announcements); setLoadError(""); } catch (e) { setLoadError(e instanceof Error ? e.message : "加载公告失败"); } }, []); const startCreate = () => { setForm({ ...EMPTY_FORM }); setSelectedId(null); }; const startEdit = (a: Announcement) => { setSelectedId(a.id); setForm({ id: a.id, title: a.title, content: a.content, tag: a.tag, tag_color: a.tag_color, published: a.published, }); }; const selectRow = (a: Announcement) => { setSelectedId(a.id); if (form && form.id !== a.id) setForm(null); }; async function save() { if (!form) return; const payload: AnnouncementInput = { title: form.title.trim(), content: form.content.trim(), tag: form.tag.trim() || "公告", tag_color: form.tag_color, published: form.published, }; if (!payload.title || !payload.content) { toast("标题和内容不能为空"); return; } setSaving(true); try { if (form.id == null) { const res = await apiAdminCreateAnnouncement(payload); toast(payload.published ? "公告已发布" : "草稿已保存", "ok"); setForm(null); setSelectedId(res.announcement?.id ?? null); } else { await apiAdminUpdateAnnouncement(form.id, payload); toast("公告已更新", "ok"); setForm(null); setSelectedId(form.id); } await load(); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSaving(false); } } async function remove(a: Announcement) { setDeletingId(a.id); try { await apiAdminDeleteAnnouncement(a.id); setList((l) => { const next = l.filter((x) => x.id !== a.id); if (selectedId === a.id) setSelectedId(next[0]?.id ?? null); return next; }); if (form?.id === a.id) setForm(null); toast("公告已删除", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "删除失败"); } finally { setDeletingId(null); setDeleteTarget(null); } } return (
新建公告 ) } />
ariaLabel="公告状态筛选" size="sm" value={statusFilter} onChange={setStatusFilter} 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, idx) => { const active = selectedId === a.id || form?.id === a.id; const summary = plainSummary(a.content); return (
selectRow(a)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectRow(a); } }} className="px-4 sm:px-5 py-3.5 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]" style={{ borderTop: idx === 0 ? undefined : "1px solid var(--line)", background: active ? "var(--accent-soft)" : undefined, }} >

{a.title}

{a.published ? "已发布" : "草稿"} {formatRelative(a.updated_at || a.created_at)} e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > {a.published ? ( ) : null} startEdit(a)}> setDeleteTarget(a)} > {deletingId === a.id ? ( ) : ( )}
{summary ? (

{summary}

) : null}
); }) )} {list.length === 0 && !loadError ? (
) : null}
{/* 右侧:表单 / 预览 / 占位 */}
{form ? ( void save()} onCancel={() => setForm(null)} /> ) : selected ? ( ) : (
} title="选择或新建公告" description="在左侧点选一条查看预览,或新建公告" />
)}
{/* 移动端编辑时:若只在右侧显示表单,列表仍在上方可见,符合双栏降级 */} deletingId == null && setDeleteTarget(null)} onConfirm={() => { if (deleteTarget) void remove(deleteTarget); }} />
); }