后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
265 lines
8.5 KiB
TypeScript
265 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useRef, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { ArrowLeft, FileText } from "lucide-react";
|
|
import {
|
|
apiAdminCreateSitePage,
|
|
apiAdminUpdateSitePage,
|
|
type SitePage,
|
|
type SitePageInput,
|
|
} from "@/lib/api";
|
|
import { toast } from "@/lib/toast";
|
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
|
import {
|
|
AdminField,
|
|
AdminPageHeader,
|
|
AdminSegmented,
|
|
AdminSettingsActions,
|
|
AdminSettingsGroup,
|
|
AdminSettingsPage,
|
|
AdminSettingsRow,
|
|
AdminSwitch,
|
|
} from "@/components/admin";
|
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
|
|
|
type PublishKey = "published" | "draft";
|
|
|
|
type FormState = {
|
|
slug: string;
|
|
title: string;
|
|
content: string;
|
|
excerpt: string;
|
|
published: boolean;
|
|
show_in_sidebar: boolean;
|
|
sort_order: number;
|
|
};
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
/** slug 仅允许小写字母数字与连字符 */
|
|
function normalizeSlug(raw: string): string {
|
|
return raw
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
}
|
|
|
|
export default function PageComposeClient({
|
|
mode,
|
|
initial,
|
|
}: {
|
|
mode: "create" | "edit";
|
|
initial?: SitePage | null;
|
|
}) {
|
|
const router = useRouter();
|
|
const baseline = useMemo(() => toForm(initial), [initial]);
|
|
const [form, setForm] = useState<FormState>(baseline);
|
|
const formRef = useRef(form);
|
|
const [saving, setSaving] = useState(false);
|
|
const isEdit = mode === "edit" && initial != null;
|
|
const dirty = !sameForm(form, baseline);
|
|
const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !saving);
|
|
|
|
const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => {
|
|
setForm((prev) => {
|
|
const next = typeof patch === "function" ? patch(prev) : { ...prev, ...patch };
|
|
formRef.current = next;
|
|
return next;
|
|
});
|
|
};
|
|
|
|
async function save() {
|
|
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,
|
|
};
|
|
if (!payload.slug || !payload.title || !payload.content) {
|
|
toast("slug、标题和内容不能为空");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
if (isEdit) {
|
|
await apiAdminUpdateSitePage(initial.id, payload);
|
|
toast("页面已更新", "ok");
|
|
} else {
|
|
await apiAdminCreateSitePage(payload);
|
|
toast(payload.published ? "页面已发布" : "草稿已保存", "ok");
|
|
}
|
|
router.push("/admin/pages");
|
|
router.refresh();
|
|
} catch (e) {
|
|
toast(e instanceof Error ? e.message : "保存失败");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
const saveLabel = isEdit ? "保存修改" : form.published ? "发布页面" : "保存草稿";
|
|
|
|
return (
|
|
<AdminSettingsPage className="is-fill">
|
|
<AdminPageHeader
|
|
icon={FileText}
|
|
title={isEdit ? "编辑单页" : "新建单页"}
|
|
description="关于本站、隐私政策等 Markdown 单页;可展示在首页右栏。"
|
|
actions={
|
|
<Link href="/admin/pages" className="btn btn-line btn-sm">
|
|
<ArrowLeft size={14} /> 返回列表
|
|
</Link>
|
|
}
|
|
/>
|
|
|
|
<div className="admin-settings-main admin-settings-stack">
|
|
<AdminSettingsGroup title="页面设置" description="slug 写入后即是前台地址 /p/…">
|
|
<AdminSettingsRow label="URL slug" htmlFor="page-slug" hint="/p/ 后的路径">
|
|
<div className="admin-settings-control-text flex items-center gap-1.5 min-w-0">
|
|
<span className="meta text-[13px] shrink-0">/p/</span>
|
|
<div className="admin-field-shell min-w-0 flex-1">
|
|
<input
|
|
id="page-slug"
|
|
className="admin-field-input font-mono text-[13px]"
|
|
maxLength={64}
|
|
value={form.slug}
|
|
onChange={(e) => patchForm({ slug: e.target.value })}
|
|
onBlur={() => patchForm((f) => ({ ...f, slug: normalizeSlug(f.slug) }))}
|
|
placeholder="about"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</AdminSettingsRow>
|
|
<AdminSettingsRow label="摘要" htmlFor="page-excerpt" hintBelow="侧栏「关于本站」使用这一句" wide>
|
|
<AdminField
|
|
id="page-excerpt"
|
|
label="摘要"
|
|
embed
|
|
as="textarea"
|
|
className="admin-settings-control-long"
|
|
maxLength={300}
|
|
minHeight="4.5rem"
|
|
value={form.excerpt}
|
|
onChange={(excerpt) => patchForm({ excerpt })}
|
|
placeholder="一句话介绍本页…"
|
|
/>
|
|
</AdminSettingsRow>
|
|
<AdminSettingsRow label="排序" htmlFor="page-sort" hintBelow="数字越小越靠前">
|
|
<input
|
|
id="page-sort"
|
|
type="number"
|
|
className="j13-compose-field admin-settings-control-num"
|
|
value={form.sort_order}
|
|
onChange={(e) => patchForm({ sort_order: parseInt(e.target.value, 10) || 0 })}
|
|
/>
|
|
</AdminSettingsRow>
|
|
<AdminSettingsRow label="发布状态">
|
|
<AdminSegmented<PublishKey>
|
|
role="group"
|
|
ariaLabel="发布状态"
|
|
value={form.published ? "published" : "draft"}
|
|
onChange={(key) => patchForm({ published: key === "published" })}
|
|
options={[
|
|
{ key: "published", label: "立即发布" },
|
|
{ key: "draft", label: "存为草稿" },
|
|
]}
|
|
/>
|
|
</AdminSettingsRow>
|
|
<AdminSettingsRow label="首页侧栏" hintBelow="开启后出现在「关于本站」模块">
|
|
<AdminSwitch
|
|
checked={form.show_in_sidebar}
|
|
onChange={() => patchForm((f) => ({ ...f, show_in_sidebar: !f.show_in_sidebar }))}
|
|
label="显示在首页侧栏"
|
|
/>
|
|
</AdminSettingsRow>
|
|
</AdminSettingsGroup>
|
|
|
|
<AdminSettingsGroup
|
|
className="is-fill"
|
|
title="标题与正文"
|
|
footer={
|
|
<AdminSettingsActions
|
|
dirty={dirty}
|
|
saving={saving}
|
|
saveLabel={saveLabel}
|
|
onSave={() => void save()}
|
|
onDiscard={() => {
|
|
formRef.current = baseline;
|
|
setForm(baseline);
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
<AdminSettingsRow label="标题" htmlFor="page-title" wide>
|
|
<AdminField
|
|
id="page-title"
|
|
label="标题"
|
|
embed
|
|
maxLength={200}
|
|
value={form.title}
|
|
onChange={(title) => patchForm({ title })}
|
|
placeholder="页面标题"
|
|
/>
|
|
</AdminSettingsRow>
|
|
<AdminSettingsRow className="admin-settings-md-editor" label="正文" htmlFor="page-content" wide>
|
|
<div className="admin-settings-md">
|
|
<MarkdownEditor
|
|
id="page-content"
|
|
value={form.content}
|
|
onChange={(content) => patchForm({ content })}
|
|
allowHide={false}
|
|
placeholder="请输入页面正文,支持 Markdown…"
|
|
rows={18}
|
|
onSubmitShortcut={() => void save()}
|
|
/>
|
|
</div>
|
|
</AdminSettingsRow>
|
|
</AdminSettingsGroup>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
open={leaveOpen}
|
|
title="有未保存的修改"
|
|
message="离开后,尚未写入的修改会丢失。确定离开?"
|
|
confirmLabel="离开"
|
|
danger
|
|
onCancel={cancelLeave}
|
|
onConfirm={confirmLeave}
|
|
/>
|
|
</AdminSettingsPage>
|
|
);
|
|
}
|