Files
jiang13-bbs/frontend/app/admin/announcements/AnnouncementComposeClient.tsx
freefire 803b694659 后台单页和公告改为正文优先编辑,保存与发布分开。
列表按列对照真实路径和状态,避免把 /p/ 前缀和发布操作混在一起。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 01:07:21 +08:00

454 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 { 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<FormState>(loaded);
const [saved, setSaved] = useState<Announcement | null>(initial ?? null);
const [form, setForm] = useState<FormState>(loaded);
const formRef = useRef(form);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
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<FormState> | ((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 (
<div className="admin-doc-page admin-doc-editor">
<header className="admin-doc-head">
<div className="min-w-0">
<Link href={backHref} className="admin-doc-back">
<ArrowLeft size={14} aria-hidden /> 返回列表
</Link>
<div className="admin-doc-head-title">
<h1>{isEdit ? "编辑公告" : "新建公告"}</h1>
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
</AdminStatusChip>
</div>
</div>
{canViewOnline ? (
<a
href={publicPath}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 查看线上页面
</a>
) : null}
</header>
<div className="admin-doc-split">
<div className="admin-doc-main">
<AdminField
id="anno-title"
label="标题"
maxLength={200}
value={form.title}
error={errors.title}
onChange={(title) => {
setErrors((prev) => ({ ...prev, title: undefined }));
patchForm({ title });
}}
placeholder="请输入公告标题"
/>
<div className="admin-doc-md">
<label className="admin-doc-label" htmlFor="anno-content">
正文
</label>
<MarkdownEditor
id="anno-content"
value={form.content}
onChange={(content) => {
setErrors((prev) => ({ ...prev, content: undefined }));
patchForm({ content });
}}
allowHide
allowReplyHide={false}
placeholder="请输入公告正文,支持 Markdown…"
rows={16}
onSubmitShortcut={requestSave}
/>
{errors.content ? (
<p className="admin-settings-row-error" role="alert">
{errors.content}
</p>
) : null}
</div>
</div>
<details className="admin-doc-rail" open>
<summary>公告设置</summary>
<div className="admin-doc-rail-body">
<AdminField
id="anno-tag"
label="标签"
hint="显示在首页右栏和公告列表的标题旁。留空保存时会写成「公告」。"
maxLength={32}
value={form.tag}
error={errors.tag}
onChange={(tag) => {
setErrors((prev) => ({ ...prev, tag: undefined }));
patchForm({ tag });
}}
placeholder="公告"
/>
<div className="admin-doc-field">
<p className="admin-doc-label">标签颜色</p>
<div className="admin-doc-colors" role="radiogroup" aria-label="标签颜色">
{ANNOUNCEMENT_COLORS.map((c) => (
<button
key={c.value}
type="button"
role="radio"
aria-checked={form.tag_color === c.value}
data-tip={c.label}
aria-label={c.label}
className={`j13-anno-dot is-${c.value}`}
onClick={() => patchForm({ tag_color: c.value })}
/>
))}
</div>
<p className="admin-doc-hint">只影响标签颜色,不改正文。</p>
{errors.tag_color ? (
<p className="admin-settings-row-error" role="alert">
{errors.tag_color}
</p>
) : null}
</div>
<div className="admin-doc-field">
<p className="admin-doc-label">置顶</p>
<div className="admin-doc-switchline">
<AdminSwitch
checked={form.pinned}
onChange={() => patchForm((f) => ({ ...f, pinned: !f.pinned }))}
label="置顶公告"
/>
<span>置顶公告</span>
</div>
<p className="admin-doc-hint">
已发布且置顶的公告排在首页右栏前面。草稿即使勾选也不会出现在前台。
</p>
</div>
{isEdit && saved ? (
<div className="admin-doc-field">
<p className="admin-doc-label">访问地址</p>
<p className="admin-doc-hint">
{publicPath}
{origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}
</p>
<p className="admin-doc-hint">地址按公告编号生成,不能改成自定义路径。</p>
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
<Copy size={14} aria-hidden /> 复制地址
</button>
</div>
) : (
<p className="admin-doc-hint">保存后才会有公开地址 /announcement/编号。草稿没有公开入口。</p>
)}
<div className="admin-doc-field">
<p className="admin-doc-label">保存后状态</p>
<AdminSegmented<PublishKey>
role="group"
ariaLabel="保存后状态"
size="sm"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "draft", label: "草稿" },
{ key: "published", label: "已发布" },
]}
/>
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
</div>
</div>
</details>
<div className="admin-doc-actions" aria-busy={saving || undefined}>
<div className="admin-doc-actions-btns">
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary"
disabled={!dirty || saving}
onClick={requestSave}
>
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
<p className="admin-doc-hint">一次保存会写入标题、正文和公告设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
{canViewOnline ? (
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
) : (
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
)}
</div>
</div>
<ConfirmDialog
open={leaveOpen}
title="有未保存的修改"
message="离开后,尚未写入的修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={cancelLeave}
onConfirm={confirmLeave}
/>
<ConfirmDialog
open={confirmUnpublish}
title="转为草稿"
message="转为草稿后,前台将无法打开此公告。这里没有单独的修订版本,线上内容会一起下线,不会保留旧版。"
confirmLabel="转为草稿"
danger
busy={saving}
onCancel={() => {
if (!saving) setConfirmUnpublish(false);
}}
onConfirm={() => void commit()}
/>
</div>
);
}