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

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

508 lines
18 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 {
apiAdminCreateSitePage,
apiAdminUpdateSitePage,
type SitePage,
type SitePageInput,
} from "@/lib/api";
import { sitePagePublicPath, sitePagePublicURL } from "@/lib/sitePagePath";
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";
type PublishKey = "published" | "draft";
type StatusFilter = "all" | "published" | "draft";
type FormState = {
slug: string;
title: string;
content: string;
excerpt: string;
published: boolean;
show_in_sidebar: boolean;
sort_order: number;
};
type FieldErrors = {
slug?: string;
title?: string;
content?: string;
excerpt?: string;
form?: string;
};
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function toForm(p?: SitePage | null): FormState {
return {
slug: p?.slug ?? "",
title: p?.title ?? "",
content: p?.content ?? "",
excerpt: p?.excerpt ?? "",
published: p?.published ?? true,
show_in_sidebar: p?.show_in_sidebar ?? false,
sort_order: p?.sort_order ?? 0,
};
}
function sameForm(a: FormState, b: FormState): boolean {
return (
a.slug === b.slug &&
a.title === b.title &&
a.content === b.content &&
a.excerpt === b.excerpt &&
a.published === b.published &&
a.show_in_sidebar === b.show_in_sidebar &&
a.sort_order === b.sort_order
);
}
function runeCount(s: string): number {
return Array.from(s).length;
}
function normalizeSlug(raw: string): string {
return raw.trim().toLowerCase();
}
function validate(form: FormState): FieldErrors {
const errors: FieldErrors = {};
const slug = normalizeSlug(form.slug);
if (!slug) errors.slug = "slug 不能为空";
else if (slug.length > 64) errors.slug = "slug 不能超过 64 字符";
else if (!SLUG_RE.test(slug)) errors.slug = "slug 仅允许小写字母、数字与连字符";
const title = form.title.trim();
if (!title) errors.title = "标题不能为空";
else if (runeCount(title) > 200) errors.title = "标题不能超过 200 字";
if (!form.content.trim()) errors.content = "内容不能为空";
if (runeCount(form.excerpt.trim()) > 300) errors.excerpt = "摘要不能超过 300 字";
return errors;
}
function errorsFromServer(msg: string): FieldErrors {
if (msg.includes("slug")) return { slug: msg };
if (msg.includes("标题")) return { title: msg };
if (msg.includes("摘要")) return { excerpt: msg };
if (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/pages" : `/admin/pages?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 PageComposeClient({
mode,
initial,
}: {
mode: "create" | "edit";
initial?: SitePage | 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 [savedPage, setSavedPage] = useState<SitePage | 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 [pendingSave, setPendingSave] = useState<{ slugChange: boolean; unpublish: boolean } | null>(
null
);
const isEdit = mode === "edit" && savedPage != 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 slugPreview = normalizeSlug(form.slug);
const publicPath = sitePagePublicPath(slugPreview);
const canViewOnline = savedPage?.published === true;
async function commit() {
if (saving) return;
const current = formRef.current;
const slug = normalizeSlug(current.slug);
const payload: SitePageInput = {
slug,
title: current.title.trim(),
content: current.content.trim(),
excerpt: current.excerpt.trim(),
published: current.published,
show_in_sidebar: current.show_in_sidebar,
sort_order: current.sort_order,
};
setSaving(true);
setErrors({});
try {
if (isEdit && savedPage) {
const res = await apiAdminUpdateSitePage(savedPage.id, payload);
const page = res.page;
const next = toForm(page);
formRef.current = next;
setForm(next);
setBaseline(next);
setSavedPage(page);
const wasPublished = baseline.published;
const nowPublished = page.published;
const msg = !wasPublished && nowPublished
? "页面已发布"
: wasPublished && !nowPublished
? "已转为草稿"
: wasPublished
? "页面已更新"
: "草稿已保存";
toast(msg, "ok");
router.refresh();
} else {
const res = await apiAdminCreateSitePage(payload);
toast(payload.published ? "页面已发布" : "草稿已保存", "ok");
const id = res.page?.id;
if (id) {
const q = status === "all" ? "" : `?status=${status}`;
router.replace(`/admin/pages/${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);
setPendingSave(null);
}
}
function requestSave() {
if (saving) return;
const current = formRef.current;
const nextErrors = validate(current);
if (hasErrors(nextErrors)) {
setErrors(nextErrors);
toast("请先修正标出的字段");
return;
}
const slug = normalizeSlug(current.slug);
const slugChange = isEdit && baseline.published && slug !== baseline.slug;
const unpublish = isEdit && baseline.published && !current.published;
if (slugChange || unpublish) {
setPendingSave({ slugChange, unpublish });
return;
}
void commit();
}
function discard() {
formRef.current = baseline;
setForm(baseline);
setErrors({});
}
async function copyAddress() {
const slug = normalizeSlug(formRef.current.slug);
const nextErrors = validate({ ...formRef.current, slug });
if (nextErrors.slug) {
setErrors((prev) => ({ ...prev, slug: nextErrors.slug }));
return;
}
const url = sitePagePublicURL(origin || window.location.origin, slug);
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
? "已保存"
: "尚未保存";
const confirmBits: string[] = [];
if (pendingSave?.unpublish) {
confirmBits.push(
"转为草稿后,前台将无法打开此页。这里没有单独的修订版本,线上内容会一起下线,不会保留旧版。"
);
}
if (pendingSave?.slugChange) {
confirmBits.push(
`访问地址将从 ${sitePagePublicPath(baseline.slug)} 改为 ${sitePagePublicPath(normalizeSlug(form.slug))}。系统不会把旧地址转到新地址,已经分享的旧链接会打不开。`
);
}
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 && savedPage ? (
<a
href={sitePagePublicPath(savedPage.slug)}
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="page-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="page-content">
正文
</label>
<MarkdownEditor
id="page-content"
value={form.content}
onChange={(content) => {
setErrors((prev) => ({ ...prev, content: undefined }));
patchForm({ content });
}}
allowHide={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">
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="page-slug">
访问地址
</label>
<div className={`admin-doc-slug${errors.slug ? " is-invalid" : ""}`}>
<span className="admin-doc-slug-prefix">/p/</span>
<input
id="page-slug"
className="admin-doc-slug-input"
maxLength={64}
value={form.slug}
aria-invalid={errors.slug ? true : undefined}
aria-describedby="page-slug-hint"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onChange={(e) => {
setErrors((prev) => ({ ...prev, slug: undefined }));
patchForm({ slug: e.target.value });
}}
onBlur={() => patchForm((f) => ({ ...f, slug: normalizeSlug(f.slug) }))}
placeholder="about"
/>
</div>
<p className="admin-doc-hint" id="page-slug-hint">
完整路径 {publicPath || "/p/"}。小写字母、数字和连字符,最多 64 字符。
{origin && slugPreview ? ` 线上地址 ${sitePagePublicURL(origin, slugPreview)}` : ""}
</p>
{isEdit && baseline.published && slugPreview !== baseline.slug ? (
<p className="admin-doc-hint">保存后访问地址会改变。旧链接不会自动跳转。</p>
) : null}
{errors.slug ? (
<p className="admin-settings-row-error" role="alert">
{errors.slug}
</p>
) : null}
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
<Copy size={14} aria-hidden /> 复制地址
</button>
</div>
<AdminField
id="page-excerpt"
label="摘要"
hint="用于首页侧栏「关于本站」、关于本站导航和单页页眉。未填写时这些位置不显示简介。"
as="textarea"
maxLength={300}
minHeight="5.5rem"
value={form.excerpt}
error={errors.excerpt}
onChange={(excerpt) => {
setErrors((prev) => ({ ...prev, excerpt: undefined }));
patchForm({ excerpt });
}}
placeholder="一句话介绍本页"
/>
<div className="admin-doc-field">
<p className="admin-doc-label">展示位置</p>
<div className="admin-doc-switchline">
<AdminSwitch
checked={form.show_in_sidebar}
onChange={() => patchForm((f) => ({ ...f, show_in_sidebar: !f.show_in_sidebar }))}
label="显示在首页侧栏"
/>
<span>显示在首页侧栏</span>
</div>
<p className="admin-doc-hint">
已发布且开启后,出现在首页右栏「关于本站」。草稿即使开启也不会出现。已发布的单页不会自动进入侧栏。
</p>
</div>
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="page-sort">
排序
</label>
<input
id="page-sort"
type="number"
className="admin-doc-number"
value={form.sort_order}
onChange={(e) => {
const n = Number.parseInt(e.target.value, 10);
patchForm({ sort_order: Number.isNaN(n) ? 0 : n });
}}
/>
<p className="admin-doc-hint">数字越小越靠前,可以为负数。</p>
</div>
<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={pendingSave != null}
title={pendingSave?.unpublish ? "转为草稿" : "访问地址将变化"}
message={confirmBits.join(" ")}
confirmLabel={pendingSave?.unpublish ? "转为草稿" : "仍然保存"}
danger={!!pendingSave?.unpublish}
busy={saving}
onCancel={() => saving || setPendingSave(null)}
onConfirm={() => void commit()}
/>
</div>
);
}