Files
jiang13-bbs/frontend/app/admin/announcements/AnnouncementAdmin.tsx
freefire a83276060f feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:52:06 +08:00

270 lines
9.3 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, useMemo, useState } from "react";
import Link from "next/link";
import {
Plus,
Pencil,
Trash2,
Loader2,
ExternalLink,
Megaphone,
Pin,
} from "lucide-react";
import {
apiAdminListAnnouncements,
apiAdminDeleteAnnouncement,
apiAdminToggleAnnouncementPin,
type Announcement,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminSegmented,
AdminStatusChip,
} from "@/components/admin";
type StatusFilter = "all" | "published" | "draft";
/** 粗略去掉 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;
}
export default function AnnouncementAdmin({
initial,
initError,
}: {
initial: Announcement[];
initError: string;
}) {
const [list, setList] = useState(initial);
const [loadError, setLoadError] = useState(initError);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [pinningId, setPinningId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(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 : "加载公告失败");
}
}, []);
async function togglePin(a: Announcement) {
if (pinningId != null) return;
setPinningId(a.id);
try {
const res = await apiAdminToggleAnnouncementPin(a.id);
setList((prev) => prev.map((x) => (x.id === a.id ? res.announcement : x)));
toast(res.announcement.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);
}
}
return (
<div>
<AdminPageHeader
icon={Megaphone}
title="站点公告管理"
description="发布、编辑与下线右栏站点公告,支持置顶与自定义标签色"
actions={
<Link href="/admin/announcements/new" className="btn btn-primary">
<Plus size={15} /> 新建公告
</Link>
}
/>
<div className="mb-4">
<AdminSegmented<StatusFilter>
ariaLabel="公告状态筛选"
size="sm"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ key: "all", label: "全部", count: list.length },
{ key: "published", label: "已发布", count: publishedCount },
{ key: "draft", label: "草稿", count: draftCount },
]}
/>
</div>
<div className="panel overflow-hidden 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 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"
}
/>
) : (
filtered.map((a, idx) => {
const summary = plainSummary(a.content);
return (
<div
key={a.id}
className="px-4 sm:px-5 py-3.5"
style={{
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
}}
>
<div className="flex items-start gap-3 min-w-0">
<span
className={`j13-anno-dot is-${a.tag_color} mt-1.5 shrink-0`}
aria-hidden
data-tip={a.tag}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
{a.pinned ? (
<span className="badge-pin shrink-0">置顶</span>
) : null}
<p
className="text-[14px] font-medium truncate max-w-full"
style={{ color: "var(--ink)" }}
data-tip={a.title}
>
{a.title}
</p>
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
{a.published ? "已发布" : "草稿"}
</AdminStatusChip>
<span className="meta text-[11px] tabular-nums shrink-0">
{formatRelative(a.updated_at || a.created_at)}
</span>
</div>
{summary ? (
<p className="meta text-[12.5px] mt-1 line-clamp-1">{summary}</p>
) : null}
</div>
<span className="inline-flex items-center gap-0.5 shrink-0">
<AdminIconButton
tip={a.pinned ? "取消置顶" : "置顶"}
ariaLabel={a.pinned ? "取消置顶" : "置顶公告"}
disabled={pinningId === a.id}
onClick={() => void togglePin(a)}
>
{pinningId === a.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Pin size={14} style={{ color: a.pinned ? "var(--accent)" : undefined }} />
)}
</AdminIconButton>
{a.published ? (
<AdminIconButton
href={`/announcement/${a.id}`}
tip="查看前台"
ariaLabel="查看公告"
>
<ExternalLink size={14} />
</AdminIconButton>
) : null}
<AdminIconButton
href={`/admin/announcements/${a.id}/edit`}
tip="编辑"
ariaLabel="编辑公告"
>
<Pencil size={14} />
</AdminIconButton>
<AdminIconButton
tip="删除"
ariaLabel="删除公告"
tone="danger"
disabled={deletingId === a.id}
onClick={() => setDeleteTarget(a)}
>
{deletingId === a.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</AdminIconButton>
</span>
</div>
</div>
);
})
)}
{list.length === 0 && !loadError ? (
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
<Link href="/admin/announcements/new" className="btn btn-primary">
<Plus size={15} /> 新建第一条公告
</Link>
</div>
) : null}
</div>
<ConfirmDialog
open={!!deleteTarget}
title="删除公告"
message={deleteTarget ? `确定删除公告「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
danger
busy={deletingId != null}
confirmLabel="删除"
onCancel={() => deletingId == null && setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) void remove(deleteTarget);
}}
/>
</div>
);
}