"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react"; import { apiAdminCreateAnnouncement, apiAdminUpdateAnnouncement, type Announcement, type AnnouncementColor, type AnnouncementInput, } from "@/lib/api"; import { toast } from "@/lib/toast"; import MarkdownEditor from "@/components/MarkdownEditor"; import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin"; import ConfirmDialog from "@/components/ConfirmDialog"; import useUnsavedGuard from "@/hooks/useUnsavedGuard"; import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColors"; type PublishKey = "published" | "draft"; type StatusFilter = "all" | "published" | "draft"; type FormState = { title: string; content: string; tag: string; tag_color: AnnouncementColor; published: boolean; pinned: boolean; }; type FieldErrors = { title?: string; content?: string; tag?: string; tag_color?: string; form?: string; }; function toForm(a?: Announcement | null): FormState { return { title: a?.title ?? "", content: a?.content ?? "", tag: a?.tag ?? "公告", tag_color: a?.tag_color ?? "blue", published: a?.published ?? true, pinned: a?.pinned ?? false, }; } function sameForm(a: FormState, b: FormState): boolean { return ( a.title === b.title && a.content === b.content && a.tag === b.tag && a.tag_color === b.tag_color && a.published === b.published && a.pinned === b.pinned ); } function runeCount(s: string): number { return Array.from(s).length; } function validate(form: FormState): FieldErrors { const errors: FieldErrors = {}; const title = form.title.trim(); if (!title) errors.title = "标题不能为空"; else if (runeCount(title) > 200) errors.title = "标题不能超过 200 字"; if (!form.content.trim()) errors.content = "内容不能为空"; const tag = form.tag.trim(); if (tag && runeCount(tag) > 32) errors.tag = "标签不能超过 32 字"; return errors; } function errorsFromServer(msg: string): FieldErrors { if (msg.includes("标题")) return { title: msg }; if (msg.includes("颜色")) return { tag_color: msg }; if (msg.includes("标签")) return { tag: msg }; if (msg.includes("内容") || msg.includes("隐藏") || msg.includes("回复")) return { content: msg }; return { form: msg }; } function hasErrors(errors: FieldErrors): boolean { return Object.values(errors).some(Boolean); } function readStatus(raw: string | null): StatusFilter { if (raw === "published" || raw === "draft") return raw; return "all"; } function listHref(status: StatusFilter): string { return status === "all" ? "/admin/announcements" : `/admin/announcements?status=${status}`; } function primaryLabel(savedPublished: boolean | null, targetPublished: boolean): string { if (savedPublished !== true && !targetPublished) return "保存草稿"; if (savedPublished !== true && targetPublished) return "发布公告"; if (savedPublished && targetPublished) return "保存并更新"; return "转为草稿"; } export default function AnnouncementComposeClient({ mode, initial, }: { mode: "create" | "edit"; initial?: Announcement | null; }) { const router = useRouter(); const searchParams = useSearchParams(); const status = readStatus(searchParams.get("status")); const backHref = listHref(status); const loaded = useMemo(() => toForm(initial), [initial]); const [baseline, setBaseline] = useState(loaded); const [saved, setSaved] = useState(initial ?? null); const [form, setForm] = useState(loaded); const formRef = useRef(form); const [saving, setSaving] = useState(false); const [errors, setErrors] = useState({}); const [origin, setOrigin] = useState(""); const [confirmUnpublish, setConfirmUnpublish] = useState(false); const isEdit = mode === "edit" && saved != null; const dirty = !sameForm(form, baseline); const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !saving); const savedPublished = isEdit ? baseline.published : null; useEffect(() => { setOrigin(window.location.origin); }, []); const patchForm = (patch: Partial | ((prev: FormState) => FormState)) => { setForm((prev) => { const next = typeof patch === "function" ? patch(prev) : { ...prev, ...patch }; formRef.current = next; return next; }); }; const canViewOnline = saved?.published === true && saved.id > 0; const publicPath = saved ? announcementPublicPath(saved.id) : ""; async function commit() { if (saving) return; const current = formRef.current; const payload: AnnouncementInput = { title: current.title.trim(), content: current.content.trim(), tag: current.tag.trim() || "公告", tag_color: current.tag_color, published: current.published, pinned: current.pinned, }; setSaving(true); setErrors({}); try { if (isEdit && saved) { const res = await apiAdminUpdateAnnouncement(saved.id, payload); const nextItem = res.announcement; const next = toForm(nextItem); const wasPublished = baseline.published; formRef.current = next; setForm(next); setBaseline(next); setSaved(nextItem); const nowPublished = nextItem.published; const msg = !wasPublished && nowPublished ? "公告已发布" : wasPublished && !nowPublished ? "已转为草稿" : wasPublished ? "公告已更新" : "草稿已保存"; toast(msg, "ok"); router.refresh(); } else { const res = await apiAdminCreateAnnouncement(payload); toast(payload.published ? "公告已发布" : "草稿已保存", "ok"); const id = res.announcement?.id; if (id) { const q = status === "all" ? "" : `?status=${status}`; router.replace(`/admin/announcements/${id}/edit${q}`); } else { router.push(backHref); } router.refresh(); } } catch (e) { const msg = e instanceof Error ? e.message : "保存失败"; setErrors(errorsFromServer(msg)); toast(msg); } finally { setSaving(false); setConfirmUnpublish(false); } } function requestSave() { if (saving) return; const current = formRef.current; const nextErrors = validate(current); if (hasErrors(nextErrors)) { setErrors(nextErrors); toast("请先修正标出的字段"); return; } if (isEdit && baseline.published && !current.published) { setConfirmUnpublish(true); return; } void commit(); } function discard() { formRef.current = baseline; setForm(baseline); setErrors({}); } async function copyAddress() { if (!saved) return; const url = `${(origin || window.location.origin).replace(/\/$/, "")}${announcementPublicPath(saved.id)}`; try { await navigator.clipboard.writeText(url); toast("已复制访问地址", "ok"); } catch { toast("复制失败"); } } const saveLabel = primaryLabel(savedPublished, form.published); const statusText = saving ? "正在保存…" : errors.form ? errors.form : dirty ? "有未保存的修改" : isEdit ? "已保存" : "尚未保存"; return (
返回列表

{isEdit ? "编辑公告" : "新建公告"}

{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
{canViewOnline ? ( 查看线上页面 ) : null}
{ setErrors((prev) => ({ ...prev, title: undefined })); patchForm({ title }); }} placeholder="请输入公告标题" />
{ setErrors((prev) => ({ ...prev, content: undefined })); patchForm({ content }); }} allowHide allowReplyHide={false} placeholder="请输入公告正文,支持 Markdown…" rows={16} onSubmitShortcut={requestSave} /> {errors.content ? (

{errors.content}

) : null}
公告设置
{ setErrors((prev) => ({ ...prev, tag: undefined })); patchForm({ tag }); }} placeholder="公告" />

标签颜色

{ANNOUNCEMENT_COLORS.map((c) => (

只影响标签颜色,不改正文。

{errors.tag_color ? (

{errors.tag_color}

) : null}

置顶

patchForm((f) => ({ ...f, pinned: !f.pinned }))} label="置顶公告" /> 置顶公告

已发布且置顶的公告排在首页右栏前面。草稿即使勾选也不会出现在前台。

{isEdit && saved ? (

访问地址

{publicPath} {origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}

地址按公告编号生成,不能改成自定义路径。

) : (

保存后才会有公开地址 /announcement/编号。草稿没有公开入口。

)}

保存后状态

role="group" ariaLabel="保存后状态" size="sm" value={form.published ? "published" : "draft"} onChange={(key) => patchForm({ published: key === "published" })} options={[ { key: "draft", label: "草稿" }, { key: "published", label: "已发布" }, ]} />

点选只改变这次要保存的状态,不会立刻发布或下线。

{statusText}

一次保存会写入标题、正文和公告设置。Ctrl/⌘ + Enter 与这个按钮相同。

{canViewOnline ? (

查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。

) : (

草稿没有公开地址。要看正文,用编辑器里的预览。

)}
{ if (!saving) setConfirmUnpublish(false); }} onConfirm={() => void commit()} />
); }