- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
"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";
|
||
|
||
/** 单页正文字符上限(与后端 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 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-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)}
|
||
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)}` : ""}
|
||
</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>
|
||
);
|
||
}
|