Files
jiang13-bbs/frontend/app/admin/announcements/AnnouncementComposeClient.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

464 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, Megaphone } 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 { useUrlStyle } from "@/components/SiteBrandProvider";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColors";
type PublishKey = "published" | "draft";
type StatusFilter = "all" | "published" | "draft";
/** 公告正文字符上限(与后端 service/announcement.go 校验一致) */
const CONTENT_MAX = 20000;
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 urlStyle = useUrlStyle();
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, urlStyle) : "";
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, urlStyle)}`;
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-sticky">
<div className="admin-doc-sticky-inner">
<div className="admin-doc-sticky-title">
<Link href={backHref} className="admin-doc-back" aria-label="返回公告列表">
<ArrowLeft size={15} aria-hidden />
<span className="admin-doc-back-text">返回列表</span>
</Link>
<span className="admin-doc-sticky-icon" aria-hidden>
<Megaphone size={15} />
</span>
<h1>{isEdit ? "编辑公告" : "新建公告"}</h1>
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
</AdminStatusChip>
</div>
<div className="admin-doc-sticky-actions">
{canViewOnline ? (
<a
href={publicPath}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line btn-sm"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 线上页面
</a>
) : null}
<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: "发布" },
]}
/>
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={requestSave}
aria-busy={saving || undefined}
>
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
</div>
</header>
<div className="admin-doc-split">
<div className="admin-doc-main">
<AdminField
className="admin-doc-title"
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: content.slice(0, CONTENT_MAX) });
}}
maxChars={CONTENT_MAX}
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">
标签颜色
<span className="admin-doc-label-value">
{ANNOUNCEMENT_COLORS.find((c) => c.value === form.tag_color)?.label}
</span>
</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>
{/* 与前台同一套 j13-anno-tag 标记,所见即所得 */}
<div className="admin-doc-tagpreview" data-note="前台胶囊效果">
<span className={`j13-anno-tag is-${form.tag_color}`}>
{form.tag.trim() || "公告"}
</span>
</div>
{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>
<div className="admin-doc-field">
<p className="admin-doc-label">访问地址</p>
{isEdit && saved ? (
<>
<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>
</>
) : (
<p className="admin-doc-hint">保存后按公告编号生成 /announcement/编号;草稿没有公开入口。</p>
)}
</div>
</div>
</details>
</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>
);
}