From 803b694659b0924d8252739ebbc9fc3609ee295d Mon Sep 17 00:00:00 2001 From: freefire Date: Sun, 20 Sep 2026 01:07:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=8D=95=E9=A1=B5=E5=92=8C?= =?UTF-8?q?=E5=85=AC=E5=91=8A=E6=94=B9=E4=B8=BA=E6=AD=A3=E6=96=87=E4=BC=98?= =?UTF-8?q?=E5=85=88=E7=BC=96=E8=BE=91=EF=BC=8C=E4=BF=9D=E5=AD=98=E4=B8=8E?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=88=86=E5=BC=80=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列表按列对照真实路径和状态,避免把 /p/ 前缀和发布操作混在一起。 Co-authored-by: Cursor --- .../admin/announcements/AnnouncementAdmin.tsx | 299 +++++++---- .../AnnouncementComposeClient.tsx | 461 +++++++++++----- .../admin/announcements/[id]/edit/page.tsx | 7 +- .../admin/announcements/announcementColors.ts | 5 + frontend/app/admin/announcements/new/page.tsx | 7 +- frontend/app/admin/announcements/page.tsx | 7 +- .../app/admin/pages/PageComposeClient.tsx | 507 +++++++++++++----- frontend/app/admin/pages/PagesAdmin.tsx | 274 ++++++---- frontend/app/admin/pages/[id]/edit/page.tsx | 7 +- frontend/app/admin/pages/new/page.tsx | 7 +- frontend/app/admin/pages/page.tsx | 7 +- frontend/app/globals.css | 442 +++++++++++++++ frontend/components/MarkdownEditor.tsx | 40 +- frontend/hooks/useUnsavedGuard.ts | 1 + frontend/lib/sitePagePath.ts | 13 + 15 files changed, 1611 insertions(+), 473 deletions(-) create mode 100644 frontend/lib/sitePagePath.ts diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx index 22e44fe..f3f4aa5 100644 --- a/frontend/app/admin/announcements/AnnouncementAdmin.tsx +++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx @@ -1,38 +1,48 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Ellipsis, ExternalLink, Loader2, Megaphone, Plus } from "lucide-react"; import { - Plus, - Pencil, - Trash2, - Loader2, - ExternalLink, - Megaphone, - Pin, -} from "lucide-react"; -import { - apiAdminListAnnouncements, apiAdminDeleteAnnouncement, + apiAdminListAnnouncements, apiAdminToggleAnnouncementPin, type Announcement, } from "@/lib/api"; -import { formatRelative } from "@/lib/format"; +import { formatDateTime, formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import ConfirmDialog from "@/components/ConfirmDialog"; import { AdminEmpty, - AdminIconButton, AdminPageHeader, AdminSegmented, - AdminSettingsPage, AdminStatusChip, } from "@/components/admin"; +import { announcementPublicPath } from "./announcementColors"; type StatusFilter = "all" | "published" | "draft"; -/** 粗略去掉 Markdown,取一行摘要 */ -function plainSummary(md: string, max = 96): string { +function readStatus(raw: string | null): StatusFilter { + if (raw === "published" || raw === "draft") return raw; + return "all"; +} + +function listHref(status: StatusFilter): string { + return status === "all" ? "/admin/announcements" : `/admin/announcements?status=${status}`; +} + +function newHref(status: StatusFilter): string { + return status === "all" ? "/admin/announcements/new" : `/admin/announcements/new?status=${status}`; +} + +function editHref(id: number, status: StatusFilter): string { + const base = `/admin/announcements/${id}/edit`; + return status === "all" ? base : `${base}?status=${status}`; +} + +/** 粗略去掉 Markdown,取一行摘要。只用于列表预览,不改库存正文。 */ +function plainSummary(md: string, max = 80): string { const text = md .replace(/```[\s\S]*?```/g, " ") .replace(/!\[[^\]]*\]\([^)]+\)/g, " ") @@ -50,12 +60,17 @@ export default function AnnouncementAdmin({ initial: Announcement[]; 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("all"); const [pinningId, setPinningId] = useState(null); const [deletingId, setDeletingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [menuId, setMenuId] = useState(null); + const [menuUp, setMenuUp] = useState(false); + const menuRef = useRef(null); const publishedCount = useMemo(() => list.filter((a) => a.published).length, [list]); const draftCount = useMemo(() => list.filter((a) => !a.published).length, [list]); @@ -76,14 +91,32 @@ export default function AnnouncementAdmin({ } }, []); + 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 togglePin(a: Announcement) { if (pinningId != null) return; setPinningId(a.id); try { - const res = await apiAdminToggleAnnouncementPin(a.id); - setList((prev) => prev.map((x) => (x.id === a.id ? res.announcement : x))); - toast(res.announcement.pinned ? "已置顶" : "已取消置顶", "ok"); - // 置顶后列表顺序可能变化,重新拉一次对齐后端排序 + await apiAdminToggleAnnouncementPin(a.id); + toast(a.pinned ? "已取消置顶" : "已置顶", "ok"); await load(); } catch (e) { toast(e instanceof Error ? e.message : "置顶失败"); @@ -103,17 +136,18 @@ export default function AnnouncementAdmin({ } finally { setDeletingId(null); setDeleteTarget(null); + setMenuId(null); } } return ( - +
+ 新建公告 } @@ -124,7 +158,7 @@ export default function AnnouncementAdmin({ ariaLabel="公告状态筛选" size="sm" value={statusFilter} - onChange={setStatusFilter} + onChange={setStatus} options={[ { key: "all", label: "全部", count: list.length }, { key: "published", label: "已发布", count: publishedCount }, @@ -133,7 +167,7 @@ export default function AnnouncementAdmin({ />
-
+
) : ( - filtered.map((a, idx) => { - const summary = plainSummary(a.content); - return ( -
-
- -
-
- {a.pinned ? ( - 置顶 - ) : null} -

- {a.title} -

- {a.tag} - - {a.published ? "已发布" : "草稿"} - - - {formatRelative(a.updated_at || a.created_at)} - -
- {summary ? ( -

{summary}

- ) : null} -
- - void togglePin(a)} - > - {pinningId === a.id ? ( - - ) : ( - - )} - - {a.published ? ( - - - - ) : null} - - - - setDeleteTarget(a)} - > - {deletingId === a.id ? ( - - ) : ( - - )} - - -
-
- ); - }) +
+ + + + + + + + + + + + + {filtered.map((a) => { + const summary = plainSummary(a.content); + const updated = a.updated_at || ""; + return ( + + + + + + + + ); + })} + +
公告列表
公告状态置顶最近更新操作
+ + {a.title} + +

+ + {a.tag} + + {a.published ? {announcementPublicPath(a.id)} : null} +

+ {summary ?

{summary}

: null} +
+ + {a.published ? "已发布" : "草稿"} + + {a.pinned ? "置顶" : "未置顶"} + {updated ? ( + + ) : ( + "—" + )} + +
+ + 编辑 + + {a.published ? ( + + + 查看 + + ) : null} + +
+ + {menuId === a.id ? ( +
+ +
+ ) : null} +
+
+
+
)} {list.length === 0 && !loadError ? (
- + 新建第一条公告
) : null} + {list.length >= 100 ? ( +

后台一次最多列出 100 条。

+ ) : null}
- +
); } diff --git a/frontend/app/admin/announcements/AnnouncementComposeClient.tsx b/frontend/app/admin/announcements/AnnouncementComposeClient.tsx index 8326c73..c7fc6c8 100644 --- a/frontend/app/admin/announcements/AnnouncementComposeClient.tsx +++ b/frontend/app/admin/announcements/AnnouncementComposeClient.tsx @@ -1,9 +1,9 @@ "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, Megaphone } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react"; import { apiAdminCreateAnnouncement, apiAdminUpdateAnnouncement, @@ -13,21 +13,13 @@ import { } 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 { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin"; import ConfirmDialog from "@/components/ConfirmDialog"; import useUnsavedGuard from "@/hooks/useUnsavedGuard"; -import { ANNOUNCEMENT_COLORS } from "./announcementColors"; +import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColors"; type PublishKey = "published" | "draft"; +type StatusFilter = "all" | "published" | "draft"; type FormState = { title: string; @@ -38,6 +30,14 @@ type FormState = { pinned: boolean; }; +type FieldErrors = { + title?: string; + content?: string; + tag?: string; + tag_color?: string; + form?: string; +}; + function toForm(a?: Announcement | null): FormState { return { title: a?.title ?? "", @@ -60,6 +60,51 @@ function sameForm(a: FormState, b: FormState): boolean { ); } +function runeCount(s: string): number { + return Array.from(s).length; +} + +function validate(form: FormState): FieldErrors { + const errors: FieldErrors = {}; + const title = form.title.trim(); + if (!title) errors.title = "标题不能为空"; + else if (runeCount(title) > 200) errors.title = "标题不能超过 200 字"; + + if (!form.content.trim()) errors.content = "内容不能为空"; + + const tag = form.tag.trim(); + if (tag && runeCount(tag) > 32) errors.tag = "标签不能超过 32 字"; + return errors; +} + +function errorsFromServer(msg: string): FieldErrors { + if (msg.includes("标题")) return { title: msg }; + if (msg.includes("颜色")) return { tag_color: msg }; + if (msg.includes("标签")) return { tag: msg }; + if (msg.includes("内容") || 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/announcements" : `/admin/announcements?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 AnnouncementComposeClient({ mode, initial, @@ -68,13 +113,26 @@ export default function AnnouncementComposeClient({ initial?: Announcement | null; }) { const router = useRouter(); - const baseline = useMemo(() => toForm(initial), [initial]); - const [form, setForm] = useState(baseline); + const searchParams = useSearchParams(); + const status = readStatus(searchParams.get("status")); + const backHref = listHref(status); + const loaded = useMemo(() => toForm(initial), [initial]); + const [baseline, setBaseline] = useState(loaded); + const [saved, setSaved] = useState(initial ?? null); + const [form, setForm] = useState(loaded); const formRef = useRef(form); const [saving, setSaving] = useState(false); - const isEdit = mode === "edit" && initial != null; + const [errors, setErrors] = useState({}); + const [origin, setOrigin] = useState(""); + const [confirmUnpublish, setConfirmUnpublish] = useState(false); + const isEdit = mode === "edit" && saved != 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 | ((prev: FormState) => FormState)) => { setForm((prev) => { @@ -84,7 +142,10 @@ export default function AnnouncementComposeClient({ }); }; - async function save() { + const canViewOnline = saved?.published === true && saved.id > 0; + const publicPath = saved ? announcementPublicPath(saved.id) : ""; + + async function commit() { if (saving) return; const current = formRef.current; const payload: AnnouncementInput = { @@ -95,135 +156,275 @@ export default function AnnouncementComposeClient({ published: current.published, pinned: current.pinned, }; - if (!payload.title || !payload.content) { - toast("标题和内容不能为空"); - return; - } setSaving(true); + setErrors({}); try { - if (isEdit) { - await apiAdminUpdateAnnouncement(initial.id, payload); - toast("公告已更新", "ok"); + if (isEdit && saved) { + const res = await apiAdminUpdateAnnouncement(saved.id, payload); + const nextItem = res.announcement; + const next = toForm(nextItem); + const wasPublished = baseline.published; + formRef.current = next; + setForm(next); + setBaseline(next); + setSaved(nextItem); + const nowPublished = nextItem.published; + const msg = !wasPublished && nowPublished + ? "公告已发布" + : wasPublished && !nowPublished + ? "已转为草稿" + : wasPublished + ? "公告已更新" + : "草稿已保存"; + toast(msg, "ok"); + router.refresh(); } else { - await apiAdminCreateAnnouncement(payload); + const res = await apiAdminCreateAnnouncement(payload); toast(payload.published ? "公告已发布" : "草稿已保存", "ok"); + const id = res.announcement?.id; + if (id) { + const q = status === "all" ? "" : `?status=${status}`; + router.replace(`/admin/announcements/${id}/edit${q}`); + } else { + router.push(backHref); + } + router.refresh(); } - router.push("/admin/announcements"); - 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); + setConfirmUnpublish(false); } } - 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; + } + if (isEdit && baseline.published && !current.published) { + setConfirmUnpublish(true); + return; + } + void commit(); + } + + function discard() { + formRef.current = baseline; + setForm(baseline); + setErrors({}); + } + + async function copyAddress() { + if (!saved) return; + const url = `${(origin || window.location.origin).replace(/\/$/, "")}${announcementPublicPath(saved.id)}`; + 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 + ? "已保存" + : "尚未保存"; return ( - - - 返回列表 +
+
+
+ + 返回列表 - } - /> +
+

{isEdit ? "编辑公告" : "新建公告"}

+ + {savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"} + +
+
+ {canViewOnline ? ( + + 查看线上页面 + + ) : null} +
-
- - +
+
+ { + setErrors((prev) => ({ ...prev, title: undefined })); + patchForm({ title }); + }} + placeholder="请输入公告标题" + /> +
+ + { + setErrors((prev) => ({ ...prev, content: undefined })); + patchForm({ content }); + }} + allowHide + allowReplyHide={false} + placeholder="请输入公告正文,支持 Markdown…" + rows={16} + onSubmitShortcut={requestSave} + /> + {errors.content ? ( +

+ {errors.content} +

+ ) : null} +
+
+ +
+ 公告设置 +
patchForm({ tag })} + error={errors.tag} + onChange={(tag) => { + setErrors((prev) => ({ ...prev, tag: undefined })); + patchForm({ tag }); + }} placeholder="公告" /> - - -
- {ANNOUNCEMENT_COLORS.map((c) => ( -
-
- - - role="group" - ariaLabel="发布状态" - value={form.published ? "published" : "draft"} - onChange={(key) => patchForm({ published: key === "published" })} - options={[ - { key: "published", label: "立即发布" }, - { key: "draft", label: "存为草稿" }, - ]} - /> - - - patchForm((f) => ({ ...f, pinned: !f.pinned }))} - label="置顶公告" - /> - - - void save()} - onDiscard={() => { - formRef.current = baseline; - setForm(baseline); - }} - /> - } - > - - patchForm({ title })} - placeholder="请输入公告标题" - /> - - -
- patchForm({ content })} - allowHide - allowReplyHide={false} - placeholder="请输入公告正文,支持 Markdown…" - rows={18} - onSubmitShortcut={() => void save()} - /> +
+

标签颜色

+
+ {ANNOUNCEMENT_COLORS.map((c) => ( +
+

只影响标签颜色,不改正文。

+ {errors.tag_color ? ( +

+ {errors.tag_color} +

+ ) : null}
- - + +
+

置顶

+
+ patchForm((f) => ({ ...f, pinned: !f.pinned }))} + label="置顶公告" + /> + 置顶公告 +
+

+ 已发布且置顶的公告排在首页右栏前面。草稿即使勾选也不会出现在前台。 +

+
+ + {isEdit && saved ? ( +
+

访问地址

+

+ {publicPath} + {origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""} +

+

地址按公告编号生成,不能改成自定义路径。

+ +
+ ) : ( +

保存后才会有公开地址 /announcement/编号。草稿没有公开入口。

+ )} + +
+

保存后状态

+ + role="group" + ariaLabel="保存后状态" + size="sm" + value={form.published ? "published" : "draft"} + onChange={(key) => patchForm({ published: key === "published" })} + options={[ + { key: "draft", label: "草稿" }, + { key: "published", label: "已发布" }, + ]} + /> +

点选只改变这次要保存的状态,不会立刻发布或下线。

+
+
+
+ +
+
+ + +

+ {statusText} +

+
+

一次保存会写入标题、正文和公告设置。Ctrl/⌘ + Enter 与这个按钮相同。

+ {canViewOnline ? ( +

查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。

+ ) : ( +

草稿没有公开地址。要看正文,用编辑器里的预览。

+ )} +
- + { + if (!saving) setConfirmUnpublish(false); + }} + onConfirm={() => void commit()} + /> +
); } diff --git a/frontend/app/admin/announcements/[id]/edit/page.tsx b/frontend/app/admin/announcements/[id]/edit/page.tsx index c38edb2..eb3bf10 100644 --- a/frontend/app/admin/announcements/[id]/edit/page.tsx +++ b/frontend/app/admin/announcements/[id]/edit/page.tsx @@ -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 AdminAnnouncementEditPage({ params }: PageProps) { return ; } - return ; + return ( + 加载编辑器…

}> + +
+ ); } diff --git a/frontend/app/admin/announcements/announcementColors.ts b/frontend/app/admin/announcements/announcementColors.ts index 9cca1bb..ed018d8 100644 --- a/frontend/app/admin/announcements/announcementColors.ts +++ b/frontend/app/admin/announcements/announcementColors.ts @@ -15,3 +15,8 @@ export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[] { value: "indigo", label: "靛蓝" }, { value: "rose", label: "玫红" }, ]; + +/** 公告公开路径。前台是 /announcement/[id],列表和线上查看用同一条。 */ +export function announcementPublicPath(id: number): string { + return `/announcement/${id}`; +} diff --git a/frontend/app/admin/announcements/new/page.tsx b/frontend/app/admin/announcements/new/page.tsx index 1213792..c89cacd 100644 --- a/frontend/app/admin/announcements/new/page.tsx +++ b/frontend/app/admin/announcements/new/page.tsx @@ -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 AdminAnnouncementNewPage() { return ; } - return ; + return ( + 加载编辑器…

}> + +
+ ); } diff --git a/frontend/app/admin/announcements/page.tsx b/frontend/app/admin/announcements/page.tsx index 1b543f7..efc82f7 100644 --- a/frontend/app/admin/announcements/page.tsx +++ b/frontend/app/admin/announcements/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { cookies } from "next/headers"; import AnnouncementAdmin from "./AnnouncementAdmin"; import { authCookieHeader } from "@/lib/cookies"; @@ -28,5 +29,9 @@ export default async function AdminAnnouncementsPage() { initError = e instanceof Error ? e.message : "加载公告失败"; } - return ; + return ( + 加载公告列表…

}> + +
+ ); } diff --git a/frontend/app/admin/pages/PageComposeClient.tsx b/frontend/app/admin/pages/PageComposeClient.tsx index c798ed1..8347150 100644 --- a/frontend/app/admin/pages/PageComposeClient.tsx +++ b/frontend/app/admin/pages/PageComposeClient.tsx @@ -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(baseline); + const searchParams = useSearchParams(); + const status = readStatus(searchParams.get("status")); + const backHref = listHref(status); + const loaded = useMemo(() => toForm(initial), [initial]); + const [baseline, setBaseline] = useState(loaded); + const [savedPage, setSavedPage] = useState(initial ?? null); + const [form, setForm] = useState(loaded); const formRef = useRef(form); const [saving, setSaving] = useState(false); - const isEdit = mode === "edit" && initial != null; + const [errors, setErrors] = useState({}); + 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 | ((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 ( - - - 返回列表 +
+
+
+ + 返回列表 - } - /> +
+

{isEdit ? "编辑单页" : "新建单页"}

+ + {savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"} + +
+
+ {canViewOnline && savedPage ? ( + + 查看线上页面 + + ) : null} +
-
- - -
- /p/ -
+
+
+ { + setErrors((prev) => ({ ...prev, title: undefined })); + patchForm({ title }); + }} + placeholder="页面标题" + /> +
+ + { + setErrors((prev) => ({ ...prev, content: undefined })); + patchForm({ content }); + }} + allowHide={false} + placeholder="请输入页面正文,支持 Markdown…" + rows={16} + onSubmitShortcut={requestSave} + /> + {errors.content ? ( +

+ {errors.content} +

+ ) : null} +
+
+ +
+ 页面设置 +
+
+ +
+ /p/ 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" />
+

+ 完整路径 {publicPath || "/p/"}。小写字母、数字和连字符,最多 64 字符。 + {origin && slugPreview ? ` 线上地址 ${sitePagePublicURL(origin, slugPreview)}` : ""} +

+ {isEdit && baseline.published && slugPreview !== baseline.slug ? ( +

保存后访问地址会改变。旧链接不会自动跳转。

+ ) : null} + {errors.slug ? ( +

+ {errors.slug} +

+ ) : null} +
- - + patchForm({ excerpt })} - placeholder="一句话介绍本页…" - /> - - - patchForm({ sort_order: parseInt(e.target.value, 10) || 0 })} - /> - - - - role="group" - ariaLabel="发布状态" - value={form.published ? "published" : "draft"} - onChange={(key) => patchForm({ published: key === "published" })} - options={[ - { key: "published", label: "立即发布" }, - { key: "draft", label: "存为草稿" }, - ]} - /> - - - patchForm((f) => ({ ...f, show_in_sidebar: !f.show_in_sidebar }))} - label="显示在首页侧栏" - /> - - - - void save()} - onDiscard={() => { - formRef.current = baseline; - setForm(baseline); + error={errors.excerpt} + onChange={(excerpt) => { + setErrors((prev) => ({ ...prev, excerpt: undefined })); + patchForm({ excerpt }); }} + placeholder="一句话介绍本页" /> - } - > - - patchForm({ title })} - placeholder="页面标题" - /> - - -
- patchForm({ content })} - allowHide={false} - placeholder="请输入页面正文,支持 Markdown…" - rows={18} - onSubmitShortcut={() => void save()} - /> + +
+

展示位置

+
+ patchForm((f) => ({ ...f, show_in_sidebar: !f.show_in_sidebar }))} + label="显示在首页侧栏" + /> + 显示在首页侧栏 +
+

+ 已发布且开启后,出现在首页右栏「关于本站」。草稿即使开启也不会出现。已发布的单页不会自动进入侧栏。 +

- - + +
+ + { + const n = Number.parseInt(e.target.value, 10); + patchForm({ sort_order: Number.isNaN(n) ? 0 : n }); + }} + /> +

数字越小越靠前,可以为负数。

+
+ +
+

保存后状态

+ + role="group" + ariaLabel="保存后状态" + size="sm" + value={form.published ? "published" : "draft"} + onChange={(key) => patchForm({ published: key === "published" })} + options={[ + { key: "draft", label: "草稿" }, + { key: "published", label: "已发布" }, + ]} + /> +

点选只改变这次要保存的状态,不会立刻发布或下线。

+
+
+
+ +
+
+ + +

+ {statusText} +

+
+

一次保存会写入标题、正文和页面设置。Ctrl/⌘ + Enter 与这个按钮相同。

+ {canViewOnline ? ( +

查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。

+ ) : ( +

草稿没有公开地址。要看正文,用编辑器里的预览。

+ )} +
- + saving || setPendingSave(null)} + onConfirm={() => void commit()} + /> +
); } diff --git a/frontend/app/admin/pages/PagesAdmin.tsx b/frontend/app/admin/pages/PagesAdmin.tsx index 36e20c2..6f9dab9 100644 --- a/frontend/app/admin/pages/PagesAdmin.tsx +++ b/frontend/app/admin/pages/PagesAdmin.tsx @@ -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("all"); + const [loadError] = useState(initError); const [deletingId, setDeletingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [menuId, setMenuId] = useState(null); + const [menuUp, setMenuUp] = useState(false); + const menuRef = useRef(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 ( - +
+ 新建单页 } @@ -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({ />
-
+
) : ( - filtered.map((p, idx) => ( -
-
-
-
-

- {p.title} -

- /{p.slug} - - {p.published ? "已发布" : "草稿"} - - {p.show_in_sidebar ? ( - 侧栏 - ) : null} - - 排序 {p.sort_order} · {formatRelative(p.updated_at || p.created_at)} - -
- {p.excerpt ? ( -

{p.excerpt}

- ) : null} -
- - {p.published ? ( - - - - ) : null} - - - - setDeleteTarget(p)} - > - {deletingId === p.id ? ( - - ) : ( - - )} - - -
-
- )) +
+ + + + + + + + + + + + + + {filtered.map((p) => { + const updated = p.updated_at || ""; + const path = sitePagePublicPath(p.slug); + return ( + + + + + + + + + ); + })} + +
单页列表
页面状态展示位置排序最近更新操作
+ + {p.title} + +

+ {path} +

+ {p.excerpt ?

{p.excerpt}

: null} +
+ + {p.published ? "已发布" : "草稿"} + + {placeLabel(p)} + {p.sort_order} + + {updated ? ( + + ) : ( + "—" + )} + +
+ + 编辑 + + {p.published ? ( + + + 查看 + + ) : null} +
+ + {menuId === p.id ? ( +
+ +
+ ) : null} +
+
+
+
)} {list.length === 0 && !loadError ? (
- + 新建第一页
) : null} + {list.length >= 100 ? ( +

后台一次最多列出 100 条。

+ ) : null}
- +
); } diff --git a/frontend/app/admin/pages/[id]/edit/page.tsx b/frontend/app/admin/pages/[id]/edit/page.tsx index d5463aa..121caf5 100644 --- a/frontend/app/admin/pages/[id]/edit/page.tsx +++ b/frontend/app/admin/pages/[id]/edit/page.tsx @@ -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 ; } - return ; + return ( + 加载编辑器…

}> + +
+ ); } diff --git a/frontend/app/admin/pages/new/page.tsx b/frontend/app/admin/pages/new/page.tsx index ca71e01..7a274de 100644 --- a/frontend/app/admin/pages/new/page.tsx +++ b/frontend/app/admin/pages/new/page.tsx @@ -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 ; } - return ; + return ( + 加载编辑器…

}> + +
+ ); } diff --git a/frontend/app/admin/pages/page.tsx b/frontend/app/admin/pages/page.tsx index 3935473..7cf0814 100644 --- a/frontend/app/admin/pages/page.tsx +++ b/frontend/app/admin/pages/page.tsx @@ -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 ; + return ( + 加载单页列表…

}> + +
+ ); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index d6129f0..6e387ef 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -4219,6 +4219,437 @@ a.j13-mention-token:hover { outline-offset: 2px; } + /* 单页管理:列表与编辑各自限宽,不套用设置页 1120px */ + .admin-canvas:has(.admin-doc-page) { + background: var(--bg); + } + .admin-doc-page { + width: 100%; + margin-inline: 0; + min-width: 0; + } + .admin-doc-list { + max-width: 1240px; + } + .admin-doc-editor { + max-width: 1440px; + container-type: inline-size; + container-name: doc-editor; + } + .admin-doc-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem 1rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; + } + .admin-doc-back { + display: inline-flex; + align-items: center; + gap: 0.3rem; + min-height: 2rem; + font-size: 13px; + color: var(--ink-2); + text-decoration: none; + } + .admin-doc-back:hover { + color: var(--accent); + } + .admin-doc-back:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 0.4rem; + } + .admin-doc-head-title { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + margin-top: 0.15rem; + } + .admin-doc-head-title h1 { + margin: 0; + font-size: 22px; + line-height: 1.25; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--ink); + } + .admin-doc-split { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1.25rem; + align-items: start; + } + .admin-doc-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1rem; + } + .admin-doc-label { + display: block; + margin: 0 0 0.4rem; + font-size: 13px; + font-weight: 600; + line-height: 1.4; + color: var(--ink-2); + } + .admin-doc-hint { + margin: 0.35rem 0 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--ink-3); + overflow-wrap: anywhere; + } + .admin-doc-md .j13-md-editor:not(.is-fullscreen) { + height: clamp(16rem, calc(100dvh - 22rem), 40rem); + min-height: 0; + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.35rem 0.85rem 0.15rem; + background: var(--panel); + } + .admin-doc-md .j13-md-textarea, + .admin-doc-md .j13-md-preview { + min-height: 0; + overflow-y: auto; + } + .admin-doc-rail { + min-width: 0; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); + padding: 0.85rem 0.9rem 1rem; + } + .admin-doc-rail > summary { + cursor: pointer; + font-size: 14px; + font-weight: 650; + color: var(--ink); + list-style: none; + min-height: 2.25rem; + display: flex; + align-items: center; + } + .admin-doc-rail > summary::-webkit-details-marker { + display: none; + } + .admin-doc-rail-body { + display: flex; + flex-direction: column; + gap: 1rem; + padding-top: 0.35rem; + } + .admin-doc-field { + min-width: 0; + } + .admin-doc-slug { + display: flex; + align-items: center; + min-height: 2.5rem; + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--panel); + overflow: hidden; + } + .admin-doc-slug:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .admin-doc-slug.is-invalid { + border-color: var(--danger); + } + .admin-doc-slug-prefix { + flex-shrink: 0; + padding-left: 0.75rem; + font-size: 14px; + color: var(--ink-3); + font-family: var(--font-mono, ui-monospace, monospace); + } + .admin-doc-slug-input { + flex: 1 1 auto; + min-width: 0; + height: 2.5rem; + border: 0; + background: transparent; + padding: 0 0.75rem 0 0.15rem; + font-size: 14px; + color: var(--ink); + font-family: var(--font-mono, ui-monospace, monospace); + } + .admin-doc-slug-input:focus { + outline: none; + } + .admin-doc-copy { + margin-top: 0.55rem; + min-height: 2.25rem; + } + .admin-doc-switchline { + display: flex; + align-items: center; + gap: 0.6rem; + min-height: 2.25rem; + font-size: 14px; + color: var(--ink); + } + .admin-doc-colors { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + } + .admin-doc-colors .j13-anno-dot { + width: 2.25rem; + height: 2.25rem; + } + .admin-doc-number { + width: 7.5rem; + height: 2.5rem; + padding: 0 0.75rem; + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--panel); + color: var(--ink); + font-size: 14px; + } + .admin-doc-number:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .admin-doc-actions { + min-width: 0; + } + .admin-doc-actions-btns { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem 0.75rem; + } + .admin-doc-actions .btn { + min-height: 2.5rem; + } + .admin-doc-status { + margin: 0; + font-size: 12.5px; + color: var(--ink-3); + } + .admin-doc-status.is-error { + color: var(--danger); + } + .admin-doc-table-wrap { + min-width: 0; + } + .admin-doc-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + color: var(--ink); + } + .admin-doc-table th { + text-align: left; + font-size: 12px; + font-weight: 600; + color: var(--ink-3); + padding: 0.55rem 0.85rem; + border-bottom: 1px solid var(--line); + white-space: nowrap; + } + .admin-doc-table td { + padding: 0.75rem 0.85rem; + border-bottom: 1px solid var(--line); + vertical-align: top; + font-size: 13.5px; + color: var(--ink-2); + } + .admin-doc-table tr:last-child td { + border-bottom: 0; + } + .admin-doc-title { + display: inline; + font-size: 14.5px; + font-weight: 650; + color: var(--ink); + text-decoration: none; + overflow-wrap: anywhere; + } + .admin-doc-title:hover { + color: var(--accent); + } + .admin-doc-title:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .admin-doc-path { + margin: 0.2rem 0 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + } + .admin-doc-path code { + font-size: 12px; + color: var(--ink-3); + font-family: var(--font-mono, ui-monospace, monospace); + } + .admin-doc-excerpt { + margin: 0.25rem 0 0; + font-size: 12.5px; + line-height: 1.45; + color: var(--ink-3); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .admin-doc-row-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.15rem; + } + .admin-doc-textbtn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + min-height: 2.25rem; + min-width: 2.25rem; + padding: 0 0.55rem; + border: 0; + border-radius: 0.55rem; + background: transparent; + color: var(--ink-2); + font-size: 13.5px; + font-weight: 550; + text-decoration: none; + cursor: pointer; + font-family: inherit; + } + .admin-doc-textbtn:hover { + background: var(--accent-soft); + color: var(--accent); + } + .admin-doc-textbtn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .admin-doc-more { + position: relative; + } + .admin-doc-menu { + position: absolute; + right: 0; + top: calc(100% + 0.2rem); + z-index: 5; + min-width: 7.5rem; + padding: 0.3rem; + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--panel); + box-shadow: var(--shadow); + } + .admin-doc-menu.is-up { + top: auto; + bottom: calc(100% + 0.2rem); + } + .admin-doc-menu-item { + display: flex; + width: 100%; + min-height: 2.25rem; + align-items: center; + padding: 0 0.7rem; + border: 0; + border-radius: 0.45rem; + background: transparent; + color: var(--ink); + font-size: 13.5px; + cursor: pointer; + font-family: inherit; + text-align: left; + } + .admin-doc-menu-item.is-danger { + color: var(--danger); + } + .admin-doc-menu-item:hover:not(:disabled) { + background: var(--danger-soft); + } + .admin-doc-menu-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; + } + .admin-doc-menu-item:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + @container doc-editor (min-width: 64rem) { + .admin-doc-split { + grid-template-columns: minmax(0, 1fr) 19rem; + column-gap: 1.5rem; + row-gap: 0.85rem; + } + .admin-doc-main { + grid-column: 1; + grid-row: 1; + } + .admin-doc-rail { + grid-column: 2; + grid-row: 1 / span 2; + position: sticky; + top: 0; + max-height: calc(100dvh - 6rem); + overflow: auto; + } + .admin-doc-rail > summary { + display: none; + } + .admin-doc-rail:not([open]) > .admin-doc-rail-body { + display: flex; + } + .admin-doc-actions { + grid-column: 1; + grid-row: 2; + } + } + + @media (max-width: 760px) { + .admin-doc-table thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + } + .admin-doc-table, + .admin-doc-table tbody, + .admin-doc-table tr, + .admin-doc-table td { + display: block; + width: 100%; + } + .admin-doc-table tr { + padding: 0.35rem 0 0.6rem; + border-bottom: 1px solid var(--line); + } + .admin-doc-table td { + border-bottom: 0; + padding: 0.3rem 0.85rem; + } + .admin-doc-table td::before { + content: attr(data-label); + display: block; + margin-bottom: 0.15rem; + font-size: 12px; + font-weight: 600; + color: var(--ink-3); + } + .admin-doc-table tr:last-child { + border-bottom: 0; + } + } + .admin-status { display: inline-flex; align-items: center; @@ -7028,3 +7459,14 @@ html.j13-reduce-chrome .j13-att-card.is-fresh { transform: translateY(0); outline: none; } + +/* 单页编辑器:未分层才能盖过 .j13-md-textarea 的 min-height: 320px */ +.admin-doc-md .j13-md-editor:not(.is-fullscreen) { + height: clamp(16rem, calc(100dvh - 22rem), 40rem); + min-height: 0; +} +.admin-doc-md .j13-md-textarea, +.admin-doc-md .j13-md-preview { + min-height: 0; + overflow-y: auto; +} diff --git a/frontend/components/MarkdownEditor.tsx b/frontend/components/MarkdownEditor.tsx index 140006c..0711d62 100644 --- a/frontend/components/MarkdownEditor.tsx +++ b/frontend/components/MarkdownEditor.tsx @@ -145,6 +145,36 @@ export default function MarkdownEditor({ null ); const savedSel = useRef<{ start: number; end: number } | null>(null); + const fullscreenMounted = useRef(false); + + const rememberCaret = () => { + const el = taRef.current; + if (!el) return; + savedSel.current = { start: el.selectionStart, end: el.selectionEnd }; + }; + + const toggleFullscreen = () => { + rememberCaret(); + setFullscreen((v) => !v); + }; + + useEffect(() => { + if (!fullscreenMounted.current) { + fullscreenMounted.current = true; + return; + } + if (mode !== "write") return; + const sel = savedSel.current; + const el = taRef.current; + if (!el || !sel) return; + // 进出全屏后把焦点和光标放回退出前的位置 + const frame = requestAnimationFrame(() => { + el.focus(); + const max = el.value.length; + el.setSelectionRange(Math.min(sel.start, max), Math.min(sel.end, max)); + }); + return () => cancelAnimationFrame(frame); + }, [fullscreen]); useEffect(() => { if (!fullscreen) return; @@ -152,7 +182,10 @@ export default function MarkdownEditor({ if (e.key === "Escape") { e.preventDefault(); if (popover) setPopover(null); - else setFullscreen(false); + else { + rememberCaret(); + setFullscreen(false); + } } }; const prev = document.body.style.overflow; @@ -452,6 +485,8 @@ export default function MarkdownEditor({ const onTextareaKeyDown = (e: ReactKeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + // 输入法组字期间的 Enter 不是提交 + if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return; e.preventDefault(); onSubmitShortcut?.(); return; @@ -954,7 +989,8 @@ export default function MarkdownEditor({ className="j13-md-tool" data-tip={fullscreen ? "退出全屏 (Esc)" : "全屏写作"} aria-label={fullscreen ? "退出全屏" : "全屏写作"} - onClick={() => setFullscreen((v) => !v)} + onMouseDown={(e) => e.preventDefault()} + onClick={toggleFullscreen} > {fullscreen ? : } diff --git a/frontend/hooks/useUnsavedGuard.ts b/frontend/hooks/useUnsavedGuard.ts index 8902c62..23e3b51 100644 --- a/frontend/hooks/useUnsavedGuard.ts +++ b/frontend/hooks/useUnsavedGuard.ts @@ -36,6 +36,7 @@ export default function useUnsavedGuard(dirty: boolean) { if (!a) return; const href = a.getAttribute("href"); if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("javascript:")) return; + if (a.getAttribute("target") === "_blank") return; let url: URL; try { url = new URL(href, window.location.href); diff --git a/frontend/lib/sitePagePath.ts b/frontend/lib/sitePagePath.ts new file mode 100644 index 0000000..0d92411 --- /dev/null +++ b/frontend/lib/sitePagePath.ts @@ -0,0 +1,13 @@ +/** 单页公开路径。前台路由是 /p/[slug],列表、复制和线上查看必须用同一条。 */ +export function sitePagePublicPath(slug: string): string { + const clean = slug.trim(); + if (!clean) return "/p/"; + return `/p/${encodeURIComponent(clean)}`; +} + +/** 用当前页面的 origin 拼绝对地址,不写死域名。origin 为空时只返回路径。 */ +export function sitePagePublicURL(origin: string, slug: string): string { + const path = sitePagePublicPath(slug); + const base = origin.replace(/\/$/, ""); + return base ? `${base}${path}` : path; +}