Files
jiang13-bbs/frontend/app/admin/pages/PageComposeClient.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

506 lines
18 KiB
TypeScript
Raw Permalink 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, FileText, Loader2 } from "lucide-react";
import {
apiAdminCreateSitePage,
apiAdminUpdateSitePage,
type SitePage,
type SitePageInput,
} from "@/lib/api";
import { sitePagePublicPath, sitePagePublicURL } from "@/lib/sitePagePath";
import { useUrlStyle } from "@/components/SiteBrandProvider";
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";
/** 单页正文字符上限(与后端 service/site_page.go 校验一致) */
const CONTENT_MAX = 20000;
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 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 [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, urlStyle);
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, 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
? "已保存"
: "尚未保存";
const confirmBits: string[] = [];
if (pendingSave?.unpublish) {
confirmBits.push(
"转为草稿后,前台将无法打开此页。这里没有单独的修订版本,线上内容会一起下线,不会保留旧版。"
);
}
if (pendingSave?.slugChange) {
confirmBits.push(
`访问地址将从 ${sitePagePublicPath(baseline.slug, urlStyle)} 改为 ${sitePagePublicPath(normalizeSlug(form.slug), urlStyle)}。系统不会把旧地址转到新地址,已经分享的旧链接会打不开。`
);
}
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>
<FileText 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 && savedPage ? (
<a
href={sitePagePublicPath(savedPage.slug, urlStyle)}
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="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: content.slice(0, CONTENT_MAX) });
}}
maxChars={CONTENT_MAX}
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, urlStyle)}` : ""}
</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>
</details>
</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>
);
}