后台单页和公告改为正文优先编辑,保存与发布分开。

列表按列对照真实路径和状态,避免把 /p/ 前缀和发布操作混在一起。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 01:07:21 +08:00
parent 40bbad2c82
commit 803b694659
15 changed files with 1611 additions and 473 deletions

View File

@@ -1,31 +1,24 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, FileText } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, Copy, ExternalLink, 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,
AdminPageHeader,
AdminSegmented,
AdminSettingsActions,
AdminSettingsGroup,
AdminSettingsPage,
AdminSettingsRow,
AdminSwitch,
} from "@/components/admin";
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";
type FormState = {
slug: string;
@@ -37,6 +30,16 @@ type FormState = {
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 ?? "",
@@ -61,14 +64,57 @@ function sameForm(a: FormState, b: FormState): boolean {
);
}
/** slug 仅允许小写字母数字与连字符 */
function runeCount(s: string): number {
return Array.from(s).length;
}
function normalizeSlug(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
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({
@@ -79,13 +125,28 @@ export default function PageComposeClient({
initial?: SitePage | null;
}) {
const router = useRouter();
const baseline = useMemo(() => toForm(initial), [initial]);
const [form, setForm] = useState<FormState>(baseline);
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 isEdit = mode === "edit" && initial != null;
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) => {
@@ -95,7 +156,11 @@ export default function PageComposeClient({
});
};
async function save() {
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);
@@ -108,146 +173,314 @@ export default function PageComposeClient({
show_in_sidebar: current.show_in_sidebar,
sort_order: current.sort_order,
};
if (!payload.slug || !payload.title || !payload.content) {
toast("slug、标题和内容不能为空");
return;
}
setSaving(true);
setErrors({});
try {
if (isEdit) {
await apiAdminUpdateSitePage(initial.id, payload);
toast("页面已更新", "ok");
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 {
await apiAdminCreateSitePage(payload);
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();
}
router.push("/admin/pages");
router.refresh();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
const msg = e instanceof Error ? e.message : "保存失败";
setErrors(errorsFromServer(msg));
toast(msg);
} finally {
setSaving(false);
setPendingSave(null);
}
}
const saveLabel = isEdit ? "保存修改" : form.published ? "发布页面" : "保存草稿";
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 (
<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} /> 返回列表
<div className="admin-doc-page admin-doc-editor">
<header className="admin-doc-head">
<div className="min-w-0">
<Link href={backHref} className="admin-doc-back">
<ArrowLeft size={14} aria-hidden /> 返回列表
</Link>
}
/>
<div className="admin-doc-head-title">
<h1>{isEdit ? "编辑单页" : "新建单页"}</h1>
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
</AdminStatusChip>
</div>
</div>
{canViewOnline && savedPage ? (
<a
href={sitePagePublicPath(savedPage.slug)}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 查看线上页面
</a>
) : null}
</header>
<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">
<div className="admin-doc-split">
<div className="admin-doc-main">
<AdminField
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 });
}}
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-field-input font-mono text-[13px]"
className="admin-doc-slug-input"
maxLength={64}
value={form.slug}
onChange={(e) => patchForm({ slug: e.target.value })}
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>
</AdminSettingsRow>
<AdminSettingsRow label="摘要" htmlFor="page-excerpt" hintBelow="侧栏「关于本站」使用这一句" wide>
<AdminField
id="page-excerpt"
label="摘要"
embed
hint="用于首页侧栏「关于本站」、关于本站导航和单页页眉。未填写时这些位置不显示简介。"
as="textarea"
className="admin-settings-control-long"
maxLength={300}
minHeight="4.5rem"
minHeight="5.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);
error={errors.excerpt}
onChange={(excerpt) => {
setErrors((prev) => ({ ...prev, excerpt: undefined }));
patchForm({ excerpt });
}}
placeholder="一句话介绍本页"
/>
}
>
<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 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>
</AdminSettingsRow>
</AdminSettingsGroup>
<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 className="admin-doc-field">
<p className="admin-doc-label">保存后状态</p>
<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: "已发布" },
]}
/>
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
</div>
</div>
</details>
<div className="admin-doc-actions" aria-busy={saving || undefined}>
<div className="admin-doc-actions-btns">
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary"
disabled={!dirty || saving}
onClick={requestSave}
>
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
<p className="admin-doc-hint">一次保存会写入标题、正文和页面设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
{canViewOnline ? (
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
) : (
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
)}
</div>
</div>
<ConfirmDialog
@@ -259,6 +492,16 @@ export default function PageComposeClient({
onCancel={cancelLeave}
onConfirm={confirmLeave}
/>
</AdminSettingsPage>
<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>
);
}

View File

@@ -1,33 +1,46 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import {
Plus,
Pencil,
Trash2,
Loader2,
ExternalLink,
FileText,
} from "lucide-react";
import {
apiAdminDeleteSitePage,
type SitePage,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { useRouter, useSearchParams } from "next/navigation";
import { Ellipsis, ExternalLink, FileText, Plus } from "lucide-react";
import { apiAdminDeleteSitePage, type SitePage } from "@/lib/api";
import { formatDateTime, formatRelative } from "@/lib/format";
import { sitePagePublicPath } from "@/lib/sitePagePath";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminSegmented,
AdminSettingsPage,
AdminStatusChip,
} from "@/components/admin";
type StatusFilter = "all" | "published" | "draft";
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 newHref(status: StatusFilter): string {
return status === "all" ? "/admin/pages/new" : `/admin/pages/new?status=${status}`;
}
function editHref(id: number, status: StatusFilter): string {
const base = `/admin/pages/${id}/edit`;
return status === "all" ? base : `${base}?status=${status}`;
}
function placeLabel(p: SitePage): string {
if (!p.show_in_sidebar) return "不在侧栏";
return p.published ? "首页侧栏" : "首页侧栏(发布后)";
}
export default function PagesAdmin({
initial,
initError,
@@ -35,11 +48,16 @@ export default function PagesAdmin({
initial: SitePage[];
initError: string;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const statusFilter = readStatus(searchParams.get("status"));
const [list, setList] = useState(initial);
const [loadError, setLoadError] = useState(initError);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [loadError] = useState(initError);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SitePage | null>(null);
const [menuId, setMenuId] = useState<number | null>(null);
const [menuUp, setMenuUp] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const publishedCount = useMemo(() => list.filter((p) => p.published).length, [list]);
const draftCount = useMemo(() => list.filter((p) => !p.published).length, [list]);
@@ -50,6 +68,26 @@ export default function PagesAdmin({
return list;
}, [list, statusFilter]);
useEffect(() => {
if (menuId == null) return;
const onPointer = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuId(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuId(null);
};
document.addEventListener("mousedown", onPointer);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [menuId]);
function setStatus(next: StatusFilter) {
router.replace(listHref(next), { scroll: false });
}
async function remove(p: SitePage) {
setDeletingId(p.id);
try {
@@ -61,17 +99,18 @@ export default function PagesAdmin({
} finally {
setDeletingId(null);
setDeleteTarget(null);
setMenuId(null);
}
}
return (
<AdminSettingsPage>
<div className="admin-doc-page admin-doc-list">
<AdminPageHeader
icon={FileText}
title="单页管理"
description="关于本站、隐私政策等自定义 Markdown 单页;可展示在首页右栏"
description="关于本站、功能介绍等自定义页面。已发布且开启侧栏的,会出现在首页右栏。"
actions={
<Link href="/admin/pages/new" className="btn btn-primary">
<Link href={newHref(statusFilter)} className="btn btn-primary">
<Plus size={15} /> 新建单页
</Link>
}
@@ -82,7 +121,7 @@ export default function PagesAdmin({
ariaLabel="单页状态筛选"
size="sm"
value={statusFilter}
onChange={setStatusFilter}
onChange={setStatus}
options={[
{ key: "all", label: "全部", count: list.length },
{ key: "published", label: "已发布", count: publishedCount },
@@ -91,7 +130,7 @@ export default function PagesAdmin({
/>
</div>
<div className="panel overflow-hidden min-w-0">
<div className="panel min-w-0">
<div
className="px-4 sm:px-5 py-3 flex items-center justify-between"
style={{ borderBottom: "1px solid var(--line)" }}
@@ -111,88 +150,137 @@ export default function PagesAdmin({
description={list.length === 0 ? "创建关于本站等内容页" : "试试切换筛选条件"}
/>
) : (
filtered.map((p, idx) => (
<div
key={p.id}
className="px-4 sm:px-5 py-3.5"
style={{
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
}}
>
<div className="flex items-start gap-3 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<p
className="text-[14px] font-medium truncate max-w-full"
style={{ color: "var(--ink)" }}
data-tip={p.title}
>
{p.title}
</p>
<code className="meta text-[11.5px] tabular-nums">/{p.slug}</code>
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
{p.published ? "已发布" : "草稿"}
</AdminStatusChip>
{p.show_in_sidebar ? (
<AdminStatusChip tone="accent">侧栏</AdminStatusChip>
) : null}
<span className="meta text-[11px] tabular-nums shrink-0">
排序 {p.sort_order} · {formatRelative(p.updated_at || p.created_at)}
</span>
</div>
{p.excerpt ? (
<p className="meta text-[12.5px] mt-1 line-clamp-1">{p.excerpt}</p>
) : null}
</div>
<span className="inline-flex items-center gap-0.5 shrink-0">
{p.published ? (
<AdminIconButton
href={`/p/${p.slug}`}
tip="查看前台"
ariaLabel="查看单页"
>
<ExternalLink size={14} />
</AdminIconButton>
) : null}
<AdminIconButton
href={`/admin/pages/${p.id}/edit`}
tip="编辑"
ariaLabel="编辑单页"
>
<Pencil size={14} />
</AdminIconButton>
<AdminIconButton
tip="删除"
ariaLabel="删除单页"
tone="danger"
disabled={deletingId === p.id}
onClick={() => setDeleteTarget(p)}
>
{deletingId === p.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</AdminIconButton>
</span>
</div>
</div>
))
<div className="admin-doc-table-wrap">
<table className="admin-doc-table">
<caption className="sr-only">单页列表</caption>
<thead>
<tr>
<th scope="col">页面</th>
<th scope="col">状态</th>
<th scope="col">展示位置</th>
<th scope="col">排序</th>
<th scope="col">最近更新</th>
<th scope="col">操作</th>
</tr>
</thead>
<tbody>
{filtered.map((p) => {
const updated = p.updated_at || "";
const path = sitePagePublicPath(p.slug);
return (
<tr key={p.id}>
<td data-label="页面">
<Link href={editHref(p.id, statusFilter)} className="admin-doc-title" title={p.title}>
{p.title}
</Link>
<p className="admin-doc-path">
<code>{path}</code>
</p>
{p.excerpt ? <p className="admin-doc-excerpt">{p.excerpt}</p> : null}
</td>
<td data-label="状态">
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
{p.published ? "已发布" : "草稿"}
</AdminStatusChip>
</td>
<td data-label="展示位置">{placeLabel(p)}</td>
<td data-label="排序" className="tabular-nums">
{p.sort_order}
</td>
<td data-label="最近更新">
{updated ? (
<time dateTime={updated} title={formatDateTime(updated)}>
{formatRelative(updated)}
</time>
) : (
"—"
)}
</td>
<td data-label="操作">
<div className="admin-doc-row-actions">
<Link href={editHref(p.id, statusFilter)} className="admin-doc-textbtn">
编辑
</Link>
{p.published ? (
<a
href={path}
target="_blank"
rel="noopener noreferrer"
className="admin-doc-textbtn"
>
<ExternalLink size={14} aria-hidden />
查看
</a>
) : null}
<div
className="admin-doc-more"
ref={menuId === p.id ? menuRef : undefined}
>
<button
type="button"
className="admin-doc-textbtn"
aria-haspopup="menu"
aria-expanded={menuId === p.id}
aria-label={`「${p.title}」的更多操作`}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setMenuUp(rect.bottom > window.innerHeight - 140);
setMenuId((cur) => (cur === p.id ? null : p.id));
}}
>
<Ellipsis size={16} aria-hidden />
更多
</button>
{menuId === p.id ? (
<div
className={`admin-doc-menu${menuUp ? " is-up" : ""}`}
role="menu"
>
<button
type="button"
role="menuitem"
className="admin-doc-menu-item is-danger"
disabled={deletingId === p.id}
onClick={() => {
setMenuId(null);
setDeleteTarget(p);
}}
>
删除
</button>
</div>
) : null}
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{list.length === 0 && !loadError ? (
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
<Link href="/admin/pages/new" className="btn btn-primary">
<Link href={newHref(statusFilter)} className="btn btn-primary">
<Plus size={15} /> 新建第一页
</Link>
</div>
) : null}
{list.length >= 100 ? (
<p className="meta text-[12px] px-4 sm:px-5 py-3">后台一次最多列出 100 条。</p>
) : null}
</div>
<ConfirmDialog
open={!!deleteTarget}
title="删除单页"
message={deleteTarget ? `确定删除「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
message={
deleteTarget
? `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,后台也不能恢复。`
: ""
}
danger
busy={deletingId != null}
confirmLabel="删除"
@@ -201,6 +289,6 @@ export default function PagesAdmin({
if (deleteTarget) void remove(deleteTarget);
}}
/>
</AdminSettingsPage>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
@@ -27,5 +28,9 @@ export default async function AdminPageEditPage({ params }: PageProps) {
return <Forbidden text="无效的页面 ID。" />;
}
return <PageEditLoader id={numId} />;
return (
<Suspense fallback={<p className="meta px-1 py-6">加载编辑器…</p>}>
<PageEditLoader id={numId} />
</Suspense>
);
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
@@ -17,5 +18,9 @@ export default async function AdminPageNewPage() {
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
}
return <PageComposeClient mode="create" />;
return (
<Suspense fallback={<p className="meta px-1 py-6">加载编辑器…</p>}>
<PageComposeClient mode="create" />
</Suspense>
);
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { cookies } from "next/headers";
import PagesAdmin from "./PagesAdmin";
import { authCookieHeader } from "@/lib/cookies";
@@ -27,5 +28,9 @@ export default async function AdminPagesPage() {
initError = e instanceof Error ? e.message : "加载单页失败";
}
return <PagesAdmin initial={initial} initError={initError} />;
return (
<Suspense fallback={<p className="meta px-1 py-6">加载单页列表…</p>}>
<PagesAdmin initial={initial} initError={initError} />
</Suspense>
);
}