"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { ArrowLeft, Copy, ExternalLink, FileText, 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(loaded); const [savedPage, setSavedPage] = 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 [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 | ((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 (
返回列表

{isEdit ? "编辑单页" : "新建单页"}

{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
{canViewOnline && savedPage ? ( 线上页面 ) : null} role="group" ariaLabel="保存后状态" size="sm" value={form.published ? "published" : "draft"} onChange={(key) => patchForm({ published: key === "published" })} options={[ { key: "draft", label: "草稿" }, { key: "published", label: "发布" }, ]} />

{statusText}

{ setErrors((prev) => ({ ...prev, title: undefined })); patchForm({ title }); }} placeholder="页面标题" />
{ setErrors((prev) => ({ ...prev, content: undefined })); patchForm({ content }); }} allowHide={false} placeholder="请输入页面正文,支持 Markdown…" rows={16} onSubmitShortcut={requestSave} /> {errors.content ? (

{errors.content}

) : null}
页面设置
/p/ { setErrors((prev) => ({ ...prev, slug: undefined })); patchForm({ slug: e.target.value }); }} onBlur={() => patchForm((f) => ({ ...f, slug: normalizeSlug(f.slug) }))} placeholder="about" />

完整路径 {publicPath || "/p/"}。小写字母、数字和连字符,最多 64 字符。 {origin && slugPreview ? ` 线上地址 ${sitePagePublicURL(origin, slugPreview)}` : ""}

{isEdit && baseline.published && slugPreview !== baseline.slug ? (

保存后访问地址会改变。旧链接不会自动跳转。

) : null} {errors.slug ? (

{errors.slug}

) : null}
{ setErrors((prev) => ({ ...prev, excerpt: undefined })); patchForm({ excerpt }); }} placeholder="一句话介绍本页" />

展示位置

patchForm((f) => ({ ...f, show_in_sidebar: !f.show_in_sidebar }))} label="显示在首页侧栏" /> 显示在首页侧栏

已发布且开启后,出现在首页右栏「关于本站」。草稿即使开启也不会出现。已发布的单页不会自动进入侧栏。

{ const n = Number.parseInt(e.target.value, 10); patchForm({ sort_order: Number.isNaN(n) ? 0 : n }); }} />

数字越小越靠前,可以为负数。

saving || setPendingSave(null)} onConfirm={() => void commit()} />
); }