关闭后前台隐藏私聊/群聊入口并由后端拦截;同步去掉公告页重复计数胶囊。 Co-authored-by: Cursor <cursoragent@cursor.com>
506 lines
16 KiB
TypeScript
506 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|