"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);
}}
/>
);
}