Files
jiang13-bbs/frontend/app/admin/announcements/AnnouncementAdmin.tsx
freefire 24c6dea38c feat: 完成头像上传、站点主题配置与UI优化
- 新增附件模型与上传服务,拆分头像上传/管理逻辑
- 重构用户资料接口,移除直接修改头像字段,改用专用上传接口
- 添加站点公开设置API与管理员外观配置页
- 全局替换hover背景为accent-soft,优化交互一致性
- 完善头像组件,支持图片加载失败回退与src更新重置
- 新增用户状态事件总线,解决同页头像更新不及时问题
- 调整依赖包顺序与CI依赖版本
- 重构板块列表接口,新增帖子计数
- 迁移账号设置到用户主页tab,合并重复代码
2026-09-15 01:06:12 +08:00

373 lines
13 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, 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(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
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(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
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>
);
}