Files
jiang13-bbs/frontend/app/admin/announcements/AnnouncementAdmin.tsx
freefire 4b93739468 feat: 后台增加开放消息开关
关闭后前台隐藏私聊/群聊入口并由后端拦截;同步去掉公告页重复计数胶囊。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 04:16:10 +08:00

506 lines
16 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 {
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 (
<div className="panel p-5">
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
{form.id == null ? "新建公告" : "编辑公告"}
</p>
<AdminField
id="anno-title"
className="mb-4"
label="标题"
maxLength={200}
value={form.title}
onChange={(title) => setForm({ ...form, title })}
placeholder="请输入公告标题"
/>
<div className="flex items-end gap-4 mb-4 flex-wrap">
<div className="w-36">
<AdminField
id="anno-tag"
label="标签"
maxLength={32}
value={form.tag}
onChange={(tag) => setForm({ ...form, tag })}
placeholder="公告"
/>
</div>
<div>
<p className="field-label">标签颜色</p>
<div className="flex items-center gap-2">
{COLORS.map((c) => (
<button
key={c.value}
type="button"
data-tip={c.label}
aria-label={c.label}
aria-pressed={form.tag_color === c.value}
className={`j13-anno-dot is-${c.value}`}
onClick={() => setForm({ ...form, tag_color: c.value })}
/>
))}
</div>
</div>
</div>
<p className="field-label">发布状态</p>
<div className="mb-4">
<AdminSegmented<PublishKey>
role="group"
ariaLabel="发布状态"
value={form.published ? "published" : "draft"}
onChange={(key) => setForm({ ...form, published: key === "published" })}
options={[
{ key: "published", label: "立即发布" },
{ key: "draft", label: "存为草稿" },
]}
/>
</div>
<AdminField
id="anno-content"
label="正文(支持 Markdown)"
as="textarea"
minHeight="260px"
value={form.content}
onChange={(content) => setForm({ ...form, content })}
placeholder="请输入公告正文,支持 Markdown 语法…"
/>
<div className="flex items-center gap-2.5 mt-4">
<button type="button" onClick={onSave} disabled={saving} className="btn btn-primary flex-1 justify-center">
{saving ? (
<>
<Loader2 size={15} className="animate-spin" /> 保存中…
</>
) : (
<>
<Save size={15} /> {form.id == null ? "发布公告" : "保存修改"}
</>
)}
</button>
<button
type="button"
onClick={onCancel}
disabled={saving}
className="btn btn-line"
aria-label="取消编辑"
>
<X size={15} /> 取消
</button>
</div>
</div>
);
}
function AnnouncementPreview({ a }: { a: Announcement }) {
return (
<div className="panel p-5">
<div className="flex items-center gap-2 flex-wrap mb-3">
<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-[11.5px] tabular-nums">{formatRelative(a.updated_at || a.created_at)}</span>
</div>
<h2 className="text-[17px] font-semibold leading-snug" style={{ color: "var(--ink)" }}>
{a.title}
</h2>
<p className="mt-3 text-[13.5px] leading-relaxed whitespace-pre-wrap" style={{ color: "var(--ink-2)" }}>
{a.content}
</p>
{a.published ? (
<a
href={`/announcement/${a.id}`}
className="btn btn-line btn-sm mt-4 inline-flex"
target="_blank"
rel="noreferrer"
>
<ExternalLink size={14} /> 查看前台页
</a>
) : null}
</div>
);
}
export default function AnnouncementAdmin({
initial,
initError,
}: {
initial: Announcement[];
initError: string;
}) {
const [list, setList] = useState(initial);
const [loadError, setLoadError] = useState(initError);
const [form, setForm] = useState<FormState | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(initial[0]?.id ?? null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [saving, setSaving] = useState(false);
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 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 (
<div>
<AdminPageHeader
icon={Megaphone}
title="站点公告管理"
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
actions={
form?.id == null && form != null ? undefined : (
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
)
}
/>
<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="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* 左侧列表 */}
<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 active = selectedId === a.id || form?.id === a.id;
const summary = plainSummary(a.content);
return (
<div
key={a.id}
role="button"
tabIndex={0}
onClick={() => 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,
}}
>
<div className="flex items-start gap-2 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<p
className="text-[14px] font-medium truncate max-w-full"
style={{ color: "var(--ink)" }}
data-tip={a.title}
>
{a.title}
</p>
<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>
<span
className="inline-flex items-center gap-0.5 shrink-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{a.published ? (
<AdminIconButton
href={`/announcement/${a.id}`}
tip="查看"
ariaLabel="查看公告"
>
<ExternalLink size={14} />
</AdminIconButton>
) : null}
<AdminIconButton tip="编辑" ariaLabel="编辑公告" onClick={() => startEdit(a)}>
<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>
{summary ? (
<p className="meta text-[12.5px] mt-1 line-clamp-1">{summary}</p>
) : null}
</div>
</div>
</div>
);
})
)}
{list.length === 0 && !loadError ? (
<div className="px-4 sm:px-5 pb-5 -mt-4 flex justify-center">
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建第一条公告
</button>
</div>
) : null}
</div>
{/* 右侧:表单 / 预览 / 占位 */}
<div className="min-w-0 lg:sticky lg:top-4">
{form ? (
<AnnouncementForm
form={form}
setForm={setForm}
saving={saving}
onSave={() => void save()}
onCancel={() => setForm(null)}
/>
) : selected ? (
<AnnouncementPreview a={selected} />
) : (
<div className="panel p-8">
<AdminEmpty
icon={<Megaphone size={22} />}
title="选择或新建公告"
description="在左侧点选一条查看预览,或新建公告"
/>
<div className="flex justify-center -mt-4 pb-2">
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
</div>
</div>
)}
</div>
</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>
);
}