公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { ArrowLeft, FileText } from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateSitePage,
|
||||
apiAdminUpdateSitePage,
|
||||
@@ -12,7 +12,18 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import { AdminSegmented, AdminSwitch } from "@/components/admin";
|
||||
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";
|
||||
|
||||
@@ -38,6 +49,18 @@ function toForm(p?: SitePage | null): FormState {
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
@@ -56,10 +79,13 @@ export default function PageComposeClient({
|
||||
initial?: SitePage | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<FormState>(() => toForm(initial));
|
||||
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) => {
|
||||
@@ -104,125 +130,114 @@ export default function PageComposeClient({
|
||||
}
|
||||
}
|
||||
|
||||
const saveLabel = isEdit ? "保存修改" : form.published ? "发布页面" : "保存草稿";
|
||||
|
||||
return (
|
||||
<div className="j13-compose-page w-full">
|
||||
<div className="j13-compose-stage">
|
||||
<header className="j13-compose-toolbar">
|
||||
<h1 className="j13-compose-toolbar-title">{isEdit ? "编辑单页" : "新建单页"}</h1>
|
||||
<div className="j13-compose-toolbar-actions">
|
||||
<Link href="/admin/pages" className="btn btn-line btn-sm">
|
||||
<ArrowLeft size={14} /> 返回列表
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm min-w-[4.5rem]"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<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="j13-compose-body">
|
||||
<section className="j13-compose-section" aria-label="页面设置">
|
||||
<h2 className="j13-compose-section-title">页面设置</h2>
|
||||
<div className="j13-compose-typepanel space-y-4">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="page-slug">
|
||||
URL slug
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="meta text-[13px]">/p/</span>
|
||||
<input
|
||||
id="page-slug"
|
||||
className="j13-compose-field flex-1 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>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="page-excerpt">
|
||||
摘要(侧栏展示)
|
||||
</label>
|
||||
<textarea
|
||||
id="page-excerpt"
|
||||
className="j13-compose-field w-full"
|
||||
rows={3}
|
||||
maxLength={300}
|
||||
value={form.excerpt}
|
||||
onChange={(e) => patchForm({ excerpt: e.target.value })}
|
||||
placeholder="一句话介绍本页…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="page-sort">
|
||||
排序(越小越靠前)
|
||||
</label>
|
||||
<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-sort"
|
||||
type="number"
|
||||
className="j13-compose-field w-28"
|
||||
value={form.sort_order}
|
||||
onChange={(e) =>
|
||||
patchForm({ sort_order: parseInt(e.target.value, 10) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="field-label">发布状态</p>
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="发布状态"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "published", label: "立即发布" },
|
||||
{ key: "draft", label: "存为草稿" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<p className="text-[14px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
显示在首页侧栏
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5">开启后出现在「关于本站」模块</p>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={form.show_in_sidebar}
|
||||
onChange={() =>
|
||||
patchForm((f) => ({ ...f, show_in_sidebar: !f.show_in_sidebar }))
|
||||
}
|
||||
label="显示在首页侧栏"
|
||||
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>
|
||||
</section>
|
||||
|
||||
<section className="j13-compose-section j13-compose-section-writing" aria-label="标题与正文">
|
||||
<h2 className="j13-compose-section-title">标题与正文</h2>
|
||||
</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
|
||||
className="j13-compose-title"
|
||||
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={(e) => patchForm({ title: e.target.value })}
|
||||
onChange={(title) => patchForm({ title })}
|
||||
placeholder="页面标题"
|
||||
aria-label="页面标题"
|
||||
/>
|
||||
<div className="j13-compose-canvas">
|
||||
</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}
|
||||
@@ -231,33 +246,19 @@ export default function PageComposeClient({
|
||||
onSubmitShortcut={() => void save()}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="j13-compose-actions">
|
||||
<div className="j13-compose-actions-end">
|
||||
<Link href="/admin/pages" className="btn btn-line">
|
||||
取消
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={15} className="animate-spin" /> 保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={15} /> {isEdit ? "保存修改" : form.published ? "发布页面" : "保存草稿"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={leaveOpen}
|
||||
title="有未保存的修改"
|
||||
message="离开后,尚未写入的修改会丢失。确定离开?"
|
||||
confirmLabel="离开"
|
||||
danger
|
||||
onCancel={cancelLeave}
|
||||
onConfirm={confirmLeave}
|
||||
/>
|
||||
</AdminSettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AdminIconButton,
|
||||
AdminPageHeader,
|
||||
AdminSegmented,
|
||||
AdminSettingsPage,
|
||||
AdminStatusChip,
|
||||
} from "@/components/admin";
|
||||
|
||||
@@ -64,7 +65,7 @@ export default function PagesAdmin({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminSettingsPage>
|
||||
<AdminPageHeader
|
||||
icon={FileText}
|
||||
title="单页管理"
|
||||
@@ -200,6 +201,6 @@ export default function PagesAdmin({
|
||||
if (deleteTarget) void remove(deleteTarget);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AdminSettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user