feat: 新增站点公告系统与多项体验优化

此提交完成了以下核心功能与优化:
1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示
2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制
3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频
4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化
5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
2026-09-13 21:38:01 +08:00
parent 9bae6f2513
commit 996a870015
41 changed files with 2846 additions and 786 deletions

View File

@@ -0,0 +1,372 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
Plus,
Pencil,
Trash2,
Loader2,
Save,
X,
ExternalLink,
Megaphone,
FileText,
} from "lucide-react";
import {
apiAdminListAnnouncements,
apiAdminCreateAnnouncement,
apiAdminUpdateAnnouncement,
apiAdminDeleteAnnouncement,
type Announcement,
type AnnouncementColor,
type AnnouncementInput,
} from "@/lib/api";
import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast";
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 };
const EMPTY_FORM: FormState = {
id: null,
title: "",
content: "",
tag: "公告",
tag_color: "blue",
published: true,
};
export default function AnnouncementAdmin() {
const [list, setList] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await apiAdminListAnnouncements();
setList(res.announcements);
setLoadError("");
} catch (e) {
setLoadError(e instanceof Error ? e.message : "加载公告失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const startCreate = () => setForm({ ...EMPTY_FORM });
const startEdit = (a: Announcement) =>
setForm({
id: a.id,
title: a.title,
content: a.content,
tag: a.tag,
tag_color: a.tag_color,
published: a.published,
});
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) {
await apiAdminCreateAnnouncement(payload);
toast("公告已发布", "ok");
} else {
await apiAdminUpdateAnnouncement(form.id, payload);
toast("公告已更新", "ok");
}
setForm(null);
await load();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
async function remove(a: Announcement) {
if (!window.confirm(`确定删除公告「${a.title}」吗?此操作不可撤销。`)) return;
setDeletingId(a.id);
try {
await apiAdminDeleteAnnouncement(a.id);
setList((l) => l.filter((x) => x.id !== a.id));
if (form?.id === a.id) setForm(null);
toast("公告已删除", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "删除失败");
} finally {
setDeletingId(null);
}
}
return (
<div>
{/* 页头 */}
<div className="flex items-center justify-between gap-3 flex-wrap mb-5">
<div>
<h1 className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5" style={{ color: "var(--ink)" }}>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Megaphone size={18} />
</span>
站点公告管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色</p>
</div>
{form?.id == null && (
<button onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-[400px_minmax(0,1fr)] gap-6 items-start">
{/* 编辑/新建表单 */}
{form ? (
<div className="panel p-5 lg:sticky lg:top-24">
<p className="font-extrabold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
{form.id == null ? "新建公告" : "编辑公告"}
</p>
<label className="field-label" htmlFor="anno-title">
标题
</label>
<input
id="anno-title"
className="field mb-4"
maxLength={200}
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="请输入公告标题"
/>
<div className="flex items-end gap-4 mb-4 flex-wrap">
<div className="w-36">
<label className="field-label" htmlFor="anno-tag">
标签
</label>
<input
id="anno-tag"
className="field"
maxLength={32}
value={form.tag}
onChange={(e) => setForm({ ...form, tag: e.target.value })}
placeholder="公告"
/>
</div>
<div>
<p className="field-label">标签颜色</p>
<div className="flex items-center gap-2">
{COLORS.map((c) => (
<button
key={c.value}
type="button"
title={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="flex items-center gap-2 mb-4" role="group" aria-label="发布状态">
<button
type="button"
className="j13-tab"
aria-pressed={form.published}
onClick={() => setForm({ ...form, published: true })}
style={form.published ? { background: "var(--accent-soft)", borderColor: "color-mix(in srgb, var(--accent) 38%, var(--line))", color: "var(--accent)" } : undefined}
>
立即发布
</button>
<button
type="button"
className="j13-tab"
aria-pressed={!form.published}
onClick={() => setForm({ ...form, published: false })}
style={!form.published ? { background: "var(--gold-soft)", borderColor: "color-mix(in srgb, var(--gold) 38%, var(--line))", color: "var(--gold)" } : undefined}
>
存为草稿
</button>
</div>
<label className="field-label" htmlFor="anno-content">
正文(支持 Markdown)
</label>
<textarea
id="anno-content"
className="field min-h-[260px]"
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
placeholder="请输入公告正文,支持 Markdown 语法…"
/>
<div className="flex items-center gap-2.5 mt-4">
<button onClick={save} 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
onClick={() => setForm(null)}
disabled={saving}
className="btn btn-line"
aria-label="取消编辑"
>
<X size={15} /> 取消
</button>
</div>
</div>
) : (
<div className="panel p-6 text-center lg:sticky lg:top-24">
<span
className="inline-flex w-12 h-12 rounded-2xl items-center justify-center mb-3"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<FileText size={22} />
</span>
<p className="font-bold text-[14.5px]" style={{ color: "var(--ink)" }}>
公告编辑器
</p>
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
点击右上「新建公告」发布公告,
<br />
或在右侧列表中选择已有公告进行编辑。
</p>
<button onClick={startCreate} className="btn btn-primary mt-4">
<Plus size={15} /> 新建公告
</button>
</div>
)}
{/* 公告列表 */}
<div className="panel overflow-hidden">
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>
全部公告
</span>
<span className="meta text-[12px] tabular-nums">共 {list.length} 条</span>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
<Loader2 size={16} className="animate-spin" /> 加载中…
</div>
) : loadError ? (
<p className="alert-error m-4">{loadError}</p>
) : list.length === 0 ? (
<p className="meta text-center py-16 text-[13px]">还没有公告,点击「新建公告」发布第一条吧</p>
) : (
list.map((a) => (
<div
key={a.id}
className="group flex items-center gap-3 px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
{!a.published && (
<span
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] font-bold leading-[1.55]"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
草稿
</span>
)}
<span className="meta text-[11px] tabular-nums">{formatDate(a.created_at)}</span>
</div>
<p
className="mt-1 truncate text-[14px] font-semibold"
style={{ color: "var(--ink)" }}
title={a.title}
>
{a.title}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{a.published && (
<Link
href={`/announcement/${a.id}`}
target="_blank"
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
title="查看"
aria-label="查看公告"
>
<ExternalLink size={15} />
</Link>
)}
<button
onClick={() => startEdit(a)}
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
title="编辑"
aria-label="编辑公告"
>
<Pencil size={15} />
</button>
<button
onClick={() => remove(a)}
disabled={deletingId === a.id}
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)] disabled:opacity-50"
style={{ color: "var(--ink-3)" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--danger)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--ink-3)")}
title="删除"
aria-label="删除公告"
>
{deletingId === a.id ? <Loader2 size={15} className="animate-spin" /> : <Trash2 size={15} />}
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
);
}