首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
264 lines
8.6 KiB
TypeScript
264 lines
8.6 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||
import {
|
||
apiAdminCreateSitePage,
|
||
apiAdminUpdateSitePage,
|
||
type SitePage,
|
||
type SitePageInput,
|
||
} from "@/lib/api";
|
||
import { toast } from "@/lib/toast";
|
||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||
import { AdminSegmented, AdminSwitch } from "@/components/admin";
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
/** 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 [form, setForm] = useState<FormState>(() => toForm(initial));
|
||
const formRef = useRef(form);
|
||
const [saving, setSaving] = useState(false);
|
||
const isEdit = mode === "edit" && initial != null;
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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>
|
||
|
||
<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>
|
||
<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="显示在首页侧栏"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="j13-compose-section j13-compose-section-writing" aria-label="标题与正文">
|
||
<h2 className="j13-compose-section-title">标题与正文</h2>
|
||
<input
|
||
className="j13-compose-title"
|
||
maxLength={200}
|
||
value={form.title}
|
||
onChange={(e) => patchForm({ title: e.target.value })}
|
||
placeholder="页面标题"
|
||
aria-label="页面标题"
|
||
/>
|
||
<div className="j13-compose-canvas">
|
||
<MarkdownEditor
|
||
value={form.content}
|
||
onChange={(content) => patchForm({ content })}
|
||
allowHide={false}
|
||
placeholder="请输入页面正文,支持 Markdown…"
|
||
rows={18}
|
||
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>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|