后台单页和公告改为正文优先编辑,保存与发布分开。
列表按列对照真实路径和状态,避免把 /p/ 前缀和发布操作混在一起。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,38 +1,48 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Ellipsis, ExternalLink, Loader2, Megaphone, Plus } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
ExternalLink,
|
|
||||||
Megaphone,
|
|
||||||
Pin,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
apiAdminListAnnouncements,
|
|
||||||
apiAdminDeleteAnnouncement,
|
apiAdminDeleteAnnouncement,
|
||||||
|
apiAdminListAnnouncements,
|
||||||
apiAdminToggleAnnouncementPin,
|
apiAdminToggleAnnouncementPin,
|
||||||
type Announcement,
|
type Announcement,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import {
|
import {
|
||||||
AdminEmpty,
|
AdminEmpty,
|
||||||
AdminIconButton,
|
|
||||||
AdminPageHeader,
|
AdminPageHeader,
|
||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
AdminSettingsPage,
|
|
||||||
AdminStatusChip,
|
AdminStatusChip,
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
|
import { announcementPublicPath } from "./announcementColors";
|
||||||
|
|
||||||
type StatusFilter = "all" | "published" | "draft";
|
type StatusFilter = "all" | "published" | "draft";
|
||||||
|
|
||||||
/** 粗略去掉 Markdown,取一行摘要 */
|
function readStatus(raw: string | null): StatusFilter {
|
||||||
function plainSummary(md: string, max = 96): string {
|
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
|
const text = md
|
||||||
.replace(/```[\s\S]*?```/g, " ")
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
|
||||||
@@ -50,12 +60,17 @@ export default function AnnouncementAdmin({
|
|||||||
initial: Announcement[];
|
initial: Announcement[];
|
||||||
initError: string;
|
initError: string;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const statusFilter = readStatus(searchParams.get("status"));
|
||||||
const [list, setList] = useState(initial);
|
const [list, setList] = useState(initial);
|
||||||
const [loadError, setLoadError] = useState(initError);
|
const [loadError, setLoadError] = useState(initError);
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
|
||||||
const [pinningId, setPinningId] = useState<number | null>(null);
|
const [pinningId, setPinningId] = useState<number | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
|
||||||
|
const [menuId, setMenuId] = useState<number | null>(null);
|
||||||
|
const [menuUp, setMenuUp] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const publishedCount = useMemo(() => list.filter((a) => a.published).length, [list]);
|
const publishedCount = useMemo(() => list.filter((a) => a.published).length, [list]);
|
||||||
const draftCount = 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) {
|
async function togglePin(a: Announcement) {
|
||||||
if (pinningId != null) return;
|
if (pinningId != null) return;
|
||||||
setPinningId(a.id);
|
setPinningId(a.id);
|
||||||
try {
|
try {
|
||||||
const res = await apiAdminToggleAnnouncementPin(a.id);
|
await apiAdminToggleAnnouncementPin(a.id);
|
||||||
setList((prev) => prev.map((x) => (x.id === a.id ? res.announcement : x)));
|
toast(a.pinned ? "已取消置顶" : "已置顶", "ok");
|
||||||
toast(res.announcement.pinned ? "已置顶" : "已取消置顶", "ok");
|
|
||||||
// 置顶后列表顺序可能变化,重新拉一次对齐后端排序
|
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "置顶失败");
|
toast(e instanceof Error ? e.message : "置顶失败");
|
||||||
@@ -103,17 +136,18 @@ export default function AnnouncementAdmin({
|
|||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
|
setMenuId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminSettingsPage>
|
<div className="admin-doc-page admin-doc-list">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={Megaphone}
|
icon={Megaphone}
|
||||||
title="站点公告管理"
|
title="站点公告管理"
|
||||||
description="发布、编辑与下线右栏站点公告,支持置顶与自定义标签色"
|
description="已发布的公告出现在首页右栏,置顶的排在前面。"
|
||||||
actions={
|
actions={
|
||||||
<Link href="/admin/announcements/new" className="btn btn-primary">
|
<Link href={newHref(statusFilter)} className="btn btn-primary">
|
||||||
<Plus size={15} /> 新建公告
|
<Plus size={15} /> 新建公告
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
@@ -124,7 +158,7 @@ export default function AnnouncementAdmin({
|
|||||||
ariaLabel="公告状态筛选"
|
ariaLabel="公告状态筛选"
|
||||||
size="sm"
|
size="sm"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={setStatusFilter}
|
onChange={setStatus}
|
||||||
options={[
|
options={[
|
||||||
{ key: "all", label: "全部", count: list.length },
|
{ key: "all", label: "全部", count: list.length },
|
||||||
{ key: "published", label: "已发布", count: publishedCount },
|
{ key: "published", label: "已发布", count: publishedCount },
|
||||||
@@ -133,7 +167,7 @@ export default function AnnouncementAdmin({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel overflow-hidden min-w-0">
|
<div className="panel min-w-0">
|
||||||
<div
|
<div
|
||||||
className="px-4 sm:px-5 py-3 flex items-center justify-between"
|
className="px-4 sm:px-5 py-3 flex items-center justify-between"
|
||||||
style={{ borderBottom: "1px solid var(--line)" }}
|
style={{ borderBottom: "1px solid var(--line)" }}
|
||||||
@@ -155,108 +189,141 @@ export default function AnnouncementAdmin({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((a, idx) => {
|
<div className="admin-doc-table-wrap">
|
||||||
const summary = plainSummary(a.content);
|
<table className="admin-doc-table">
|
||||||
return (
|
<caption className="sr-only">公告列表</caption>
|
||||||
<div
|
<thead>
|
||||||
key={a.id}
|
<tr>
|
||||||
className="px-4 sm:px-5 py-3.5"
|
<th scope="col">公告</th>
|
||||||
style={{
|
<th scope="col">状态</th>
|
||||||
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
|
<th scope="col">置顶</th>
|
||||||
}}
|
<th scope="col">最近更新</th>
|
||||||
>
|
<th scope="col">操作</th>
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
</tr>
|
||||||
<span
|
</thead>
|
||||||
className={`j13-anno-dot is-${a.tag_color} mt-1.5 shrink-0`}
|
<tbody>
|
||||||
aria-hidden
|
{filtered.map((a) => {
|
||||||
data-tip={a.tag}
|
const summary = plainSummary(a.content);
|
||||||
/>
|
const updated = a.updated_at || "";
|
||||||
<div className="min-w-0 flex-1">
|
return (
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<tr key={a.id}>
|
||||||
{a.pinned ? (
|
<td data-label="公告">
|
||||||
<span className="badge-pin shrink-0">置顶</span>
|
<Link href={editHref(a.id, statusFilter)} className="admin-doc-title" title={a.title}>
|
||||||
) : null}
|
{a.title}
|
||||||
<p
|
</Link>
|
||||||
className="text-[14px] font-medium truncate max-w-full"
|
<p className="admin-doc-path">
|
||||||
style={{ color: "var(--ink)" }}
|
<span className={`j13-anno-tag is-${a.tag_color}`} title={a.tag}>
|
||||||
data-tip={a.title}
|
{a.tag}
|
||||||
>
|
</span>
|
||||||
{a.title}
|
{a.published ? <code>{announcementPublicPath(a.id)}</code> : null}
|
||||||
</p>
|
</p>
|
||||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
{summary ? <p className="admin-doc-excerpt" title={summary}>{summary}</p> : null}
|
||||||
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
|
</td>
|
||||||
{a.published ? "已发布" : "草稿"}
|
<td data-label="状态">
|
||||||
</AdminStatusChip>
|
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
|
||||||
<span className="meta text-[11px] tabular-nums shrink-0">
|
{a.published ? "已发布" : "草稿"}
|
||||||
{formatRelative(a.updated_at || a.created_at)}
|
</AdminStatusChip>
|
||||||
</span>
|
</td>
|
||||||
</div>
|
<td data-label="置顶">{a.pinned ? "置顶" : "未置顶"}</td>
|
||||||
{summary ? (
|
<td data-label="最近更新">
|
||||||
<p className="meta text-[12.5px] mt-1 line-clamp-1">{summary}</p>
|
{updated ? (
|
||||||
) : null}
|
<time dateTime={updated} title={formatDateTime(updated)}>
|
||||||
</div>
|
{formatRelative(updated)}
|
||||||
<span className="inline-flex items-center gap-0.5 shrink-0">
|
</time>
|
||||||
<AdminIconButton
|
) : (
|
||||||
tip={a.pinned ? "取消置顶" : "置顶"}
|
"—"
|
||||||
ariaLabel={a.pinned ? "取消置顶" : "置顶公告"}
|
)}
|
||||||
disabled={pinningId === a.id}
|
</td>
|
||||||
onClick={() => void togglePin(a)}
|
<td data-label="操作">
|
||||||
>
|
<div className="admin-doc-row-actions">
|
||||||
{pinningId === a.id ? (
|
<Link href={editHref(a.id, statusFilter)} className="admin-doc-textbtn">
|
||||||
<Loader2 size={14} className="animate-spin" />
|
编辑
|
||||||
) : (
|
</Link>
|
||||||
<Pin size={14} style={{ color: a.pinned ? "var(--accent)" : undefined }} />
|
{a.published ? (
|
||||||
)}
|
<a
|
||||||
</AdminIconButton>
|
href={announcementPublicPath(a.id)}
|
||||||
{a.published ? (
|
target="_blank"
|
||||||
<AdminIconButton
|
rel="noopener noreferrer"
|
||||||
href={`/announcement/${a.id}`}
|
className="admin-doc-textbtn"
|
||||||
tip="查看前台"
|
>
|
||||||
ariaLabel="查看公告"
|
<ExternalLink size={14} aria-hidden />
|
||||||
>
|
查看
|
||||||
<ExternalLink size={14} />
|
</a>
|
||||||
</AdminIconButton>
|
) : null}
|
||||||
) : null}
|
<button
|
||||||
<AdminIconButton
|
type="button"
|
||||||
href={`/admin/announcements/${a.id}/edit`}
|
className="admin-doc-textbtn"
|
||||||
tip="编辑"
|
disabled={pinningId === a.id}
|
||||||
ariaLabel="编辑公告"
|
onClick={() => void togglePin(a)}
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
{pinningId === a.id ? (
|
||||||
</AdminIconButton>
|
<Loader2 size={14} className="animate-spin" aria-hidden />
|
||||||
<AdminIconButton
|
) : null}
|
||||||
tip="删除"
|
{a.pinned ? "取消置顶" : "置顶"}
|
||||||
ariaLabel="删除公告"
|
</button>
|
||||||
tone="danger"
|
<div className="admin-doc-more" ref={menuId === a.id ? menuRef : undefined}>
|
||||||
disabled={deletingId === a.id}
|
<button
|
||||||
onClick={() => setDeleteTarget(a)}
|
type="button"
|
||||||
>
|
className="admin-doc-textbtn"
|
||||||
{deletingId === a.id ? (
|
aria-haspopup="menu"
|
||||||
<Loader2 size={14} className="animate-spin" />
|
aria-expanded={menuId === a.id}
|
||||||
) : (
|
aria-label={`「${a.title}」的更多操作`}
|
||||||
<Trash2 size={14} />
|
onClick={(e) => {
|
||||||
)}
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
</AdminIconButton>
|
setMenuUp(rect.bottom > window.innerHeight - 140);
|
||||||
</span>
|
setMenuId((cur) => (cur === a.id ? null : a.id));
|
||||||
</div>
|
}}
|
||||||
</div>
|
>
|
||||||
);
|
<Ellipsis size={16} aria-hidden />
|
||||||
})
|
更多
|
||||||
|
</button>
|
||||||
|
{menuId === a.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 === a.id}
|
||||||
|
onClick={() => {
|
||||||
|
setMenuId(null);
|
||||||
|
setDeleteTarget(a);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{list.length === 0 && !loadError ? (
|
{list.length === 0 && !loadError ? (
|
||||||
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
|
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
|
||||||
<Link href="/admin/announcements/new" className="btn btn-primary">
|
<Link href={newHref(statusFilter)} className="btn btn-primary">
|
||||||
<Plus size={15} /> 新建第一条公告
|
<Plus size={15} /> 新建第一条公告
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{list.length >= 100 ? (
|
||||||
|
<p className="meta text-[12px] px-4 sm:px-5 py-3">后台一次最多列出 100 条。</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title="删除公告"
|
title="删除公告"
|
||||||
message={deleteTarget ? `确定删除公告「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
|
message={
|
||||||
|
deleteTarget
|
||||||
|
? `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,后台也不能恢复。`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
danger
|
danger
|
||||||
busy={deletingId != null}
|
busy={deletingId != null}
|
||||||
confirmLabel="删除"
|
confirmLabel="删除"
|
||||||
@@ -265,6 +332,6 @@ export default function AnnouncementAdmin({
|
|||||||
if (deleteTarget) void remove(deleteTarget);
|
if (deleteTarget) void remove(deleteTarget);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AdminSettingsPage>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { ArrowLeft, Megaphone } from "lucide-react";
|
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiAdminCreateAnnouncement,
|
apiAdminCreateAnnouncement,
|
||||||
apiAdminUpdateAnnouncement,
|
apiAdminUpdateAnnouncement,
|
||||||
@@ -13,21 +13,13 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||||
import {
|
import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin";
|
||||||
AdminField,
|
|
||||||
AdminPageHeader,
|
|
||||||
AdminSegmented,
|
|
||||||
AdminSettingsActions,
|
|
||||||
AdminSettingsGroup,
|
|
||||||
AdminSettingsPage,
|
|
||||||
AdminSettingsRow,
|
|
||||||
AdminSwitch,
|
|
||||||
} from "@/components/admin";
|
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||||
import { ANNOUNCEMENT_COLORS } from "./announcementColors";
|
import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColors";
|
||||||
|
|
||||||
type PublishKey = "published" | "draft";
|
type PublishKey = "published" | "draft";
|
||||||
|
type StatusFilter = "all" | "published" | "draft";
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -38,6 +30,14 @@ type FormState = {
|
|||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FieldErrors = {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
tag?: string;
|
||||||
|
tag_color?: string;
|
||||||
|
form?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function toForm(a?: Announcement | null): FormState {
|
function toForm(a?: Announcement | null): FormState {
|
||||||
return {
|
return {
|
||||||
title: a?.title ?? "",
|
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({
|
export default function AnnouncementComposeClient({
|
||||||
mode,
|
mode,
|
||||||
initial,
|
initial,
|
||||||
@@ -68,13 +113,26 @@ export default function AnnouncementComposeClient({
|
|||||||
initial?: Announcement | null;
|
initial?: Announcement | null;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const baseline = useMemo(() => toForm(initial), [initial]);
|
const searchParams = useSearchParams();
|
||||||
const [form, setForm] = useState<FormState>(baseline);
|
const status = readStatus(searchParams.get("status"));
|
||||||
|
const backHref = listHref(status);
|
||||||
|
const loaded = useMemo(() => toForm(initial), [initial]);
|
||||||
|
const [baseline, setBaseline] = useState<FormState>(loaded);
|
||||||
|
const [saved, setSaved] = useState<Announcement | null>(initial ?? null);
|
||||||
|
const [form, setForm] = useState<FormState>(loaded);
|
||||||
const formRef = useRef(form);
|
const formRef = useRef(form);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const isEdit = mode === "edit" && initial != null;
|
const [errors, setErrors] = useState<FieldErrors>({});
|
||||||
|
const [origin, setOrigin] = useState("");
|
||||||
|
const [confirmUnpublish, setConfirmUnpublish] = useState(false);
|
||||||
|
const isEdit = mode === "edit" && saved != null;
|
||||||
const dirty = !sameForm(form, baseline);
|
const dirty = !sameForm(form, baseline);
|
||||||
const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !saving);
|
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)) => {
|
const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => {
|
||||||
setForm((prev) => {
|
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;
|
if (saving) return;
|
||||||
const current = formRef.current;
|
const current = formRef.current;
|
||||||
const payload: AnnouncementInput = {
|
const payload: AnnouncementInput = {
|
||||||
@@ -95,135 +156,275 @@ export default function AnnouncementComposeClient({
|
|||||||
published: current.published,
|
published: current.published,
|
||||||
pinned: current.pinned,
|
pinned: current.pinned,
|
||||||
};
|
};
|
||||||
if (!payload.title || !payload.content) {
|
|
||||||
toast("标题和内容不能为空");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setErrors({});
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
if (isEdit && saved) {
|
||||||
await apiAdminUpdateAnnouncement(initial.id, payload);
|
const res = await apiAdminUpdateAnnouncement(saved.id, payload);
|
||||||
toast("公告已更新", "ok");
|
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 {
|
} else {
|
||||||
await apiAdminCreateAnnouncement(payload);
|
const res = await apiAdminCreateAnnouncement(payload);
|
||||||
toast(payload.published ? "公告已发布" : "草稿已保存", "ok");
|
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) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
const msg = e instanceof Error ? e.message : "保存失败";
|
||||||
|
setErrors(errorsFromServer(msg));
|
||||||
|
toast(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
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 (
|
return (
|
||||||
<AdminSettingsPage className="is-fill">
|
<div className="admin-doc-page admin-doc-editor">
|
||||||
<AdminPageHeader
|
<header className="admin-doc-head">
|
||||||
icon={Megaphone}
|
<div className="min-w-0">
|
||||||
title={isEdit ? "编辑公告" : "新建公告"}
|
<Link href={backHref} className="admin-doc-back">
|
||||||
description="标签与置顶会出现在首页右栏;正文支持 Markdown。"
|
<ArrowLeft size={14} aria-hidden /> 返回列表
|
||||||
actions={
|
|
||||||
<Link href="/admin/announcements" className="btn btn-line btn-sm">
|
|
||||||
<ArrowLeft size={14} /> 返回列表
|
|
||||||
</Link>
|
</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 ? (
|
||||||
|
<a
|
||||||
|
href={publicPath}
|
||||||
|
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">
|
<div className="admin-doc-split">
|
||||||
<AdminSettingsGroup title="发布设置" description="标签色与置顶只影响前台展示,不改正文。">
|
<div className="admin-doc-main">
|
||||||
<AdminSettingsRow label="标签" htmlFor="anno-tag">
|
<AdminField
|
||||||
|
id="anno-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="anno-content">
|
||||||
|
正文
|
||||||
|
</label>
|
||||||
|
<MarkdownEditor
|
||||||
|
id="anno-content"
|
||||||
|
value={form.content}
|
||||||
|
onChange={(content) => {
|
||||||
|
setErrors((prev) => ({ ...prev, content: undefined }));
|
||||||
|
patchForm({ content });
|
||||||
|
}}
|
||||||
|
allowHide
|
||||||
|
allowReplyHide={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">
|
||||||
<AdminField
|
<AdminField
|
||||||
id="anno-tag"
|
id="anno-tag"
|
||||||
label="标签"
|
label="标签"
|
||||||
embed
|
hint="显示在首页右栏和公告列表的标题旁。留空保存时会写成「公告」。"
|
||||||
className="admin-settings-control-text"
|
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
value={form.tag}
|
value={form.tag}
|
||||||
onChange={(tag) => patchForm({ tag })}
|
error={errors.tag}
|
||||||
|
onChange={(tag) => {
|
||||||
|
setErrors((prev) => ({ ...prev, tag: undefined }));
|
||||||
|
patchForm({ tag });
|
||||||
|
}}
|
||||||
placeholder="公告"
|
placeholder="公告"
|
||||||
/>
|
/>
|
||||||
</AdminSettingsRow>
|
|
||||||
<AdminSettingsRow label="标签颜色">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{ANNOUNCEMENT_COLORS.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c.value}
|
|
||||||
type="button"
|
|
||||||
data-tip={c.label}
|
|
||||||
aria-label={c.label}
|
|
||||||
aria-pressed={form.tag_color === c.value}
|
|
||||||
className={`j13-anno-dot is-${c.value}`}
|
|
||||||
onClick={() => patchForm({ tag_color: c.value })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</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.pinned}
|
|
||||||
onChange={() => patchForm((f) => ({ ...f, pinned: !f.pinned }))}
|
|
||||||
label="置顶公告"
|
|
||||||
/>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
</AdminSettingsGroup>
|
|
||||||
|
|
||||||
<AdminSettingsGroup
|
<div className="admin-doc-field">
|
||||||
className="is-fill"
|
<p className="admin-doc-label">标签颜色</p>
|
||||||
title="标题与正文"
|
<div className="admin-doc-colors" role="radiogroup" aria-label="标签颜色">
|
||||||
footer={
|
{ANNOUNCEMENT_COLORS.map((c) => (
|
||||||
<AdminSettingsActions
|
<button
|
||||||
dirty={dirty}
|
key={c.value}
|
||||||
saving={saving}
|
type="button"
|
||||||
saveLabel={saveLabel}
|
role="radio"
|
||||||
onSave={() => void save()}
|
aria-checked={form.tag_color === c.value}
|
||||||
onDiscard={() => {
|
data-tip={c.label}
|
||||||
formRef.current = baseline;
|
aria-label={c.label}
|
||||||
setForm(baseline);
|
className={`j13-anno-dot is-${c.value}`}
|
||||||
}}
|
onClick={() => patchForm({ tag_color: c.value })}
|
||||||
/>
|
/>
|
||||||
}
|
))}
|
||||||
>
|
</div>
|
||||||
<AdminSettingsRow label="标题" htmlFor="anno-title" wide>
|
<p className="admin-doc-hint">只影响标签颜色,不改正文。</p>
|
||||||
<AdminField
|
{errors.tag_color ? (
|
||||||
id="anno-title"
|
<p className="admin-settings-row-error" role="alert">
|
||||||
label="标题"
|
{errors.tag_color}
|
||||||
embed
|
</p>
|
||||||
maxLength={200}
|
) : null}
|
||||||
value={form.title}
|
|
||||||
onChange={(title) => patchForm({ title })}
|
|
||||||
placeholder="请输入公告标题"
|
|
||||||
/>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
<AdminSettingsRow className="admin-settings-md-editor" label="正文" htmlFor="anno-content" wide>
|
|
||||||
<div className="admin-settings-md">
|
|
||||||
<MarkdownEditor
|
|
||||||
id="anno-content"
|
|
||||||
value={form.content}
|
|
||||||
onChange={(content) => patchForm({ content })}
|
|
||||||
allowHide
|
|
||||||
allowReplyHide={false}
|
|
||||||
placeholder="请输入公告正文,支持 Markdown…"
|
|
||||||
rows={18}
|
|
||||||
onSubmitShortcut={() => void save()}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AdminSettingsRow>
|
|
||||||
</AdminSettingsGroup>
|
<div className="admin-doc-field">
|
||||||
|
<p className="admin-doc-label">置顶</p>
|
||||||
|
<div className="admin-doc-switchline">
|
||||||
|
<AdminSwitch
|
||||||
|
checked={form.pinned}
|
||||||
|
onChange={() => patchForm((f) => ({ ...f, pinned: !f.pinned }))}
|
||||||
|
label="置顶公告"
|
||||||
|
/>
|
||||||
|
<span>置顶公告</span>
|
||||||
|
</div>
|
||||||
|
<p className="admin-doc-hint">
|
||||||
|
已发布且置顶的公告排在首页右栏前面。草稿即使勾选也不会出现在前台。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEdit && saved ? (
|
||||||
|
<div className="admin-doc-field">
|
||||||
|
<p className="admin-doc-label">访问地址</p>
|
||||||
|
<p className="admin-doc-hint">
|
||||||
|
{publicPath}
|
||||||
|
{origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}
|
||||||
|
</p>
|
||||||
|
<p className="admin-doc-hint">地址按公告编号生成,不能改成自定义路径。</p>
|
||||||
|
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
|
||||||
|
<Copy size={14} aria-hidden /> 复制地址
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="admin-doc-hint">保存后才会有公开地址 /announcement/编号。草稿没有公开入口。</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -235,6 +436,18 @@ export default function AnnouncementComposeClient({
|
|||||||
onCancel={cancelLeave}
|
onCancel={cancelLeave}
|
||||||
onConfirm={confirmLeave}
|
onConfirm={confirmLeave}
|
||||||
/>
|
/>
|
||||||
</AdminSettingsPage>
|
<ConfirmDialog
|
||||||
|
open={confirmUnpublish}
|
||||||
|
title="转为草稿"
|
||||||
|
message="转为草稿后,前台将无法打开此公告。这里没有单独的修订版本,线上内容会一起下线,不会保留旧版。"
|
||||||
|
confirmLabel="转为草稿"
|
||||||
|
danger
|
||||||
|
busy={saving}
|
||||||
|
onCancel={() => {
|
||||||
|
if (!saving) setConfirmUnpublish(false);
|
||||||
|
}}
|
||||||
|
onConfirm={() => void commit()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
@@ -27,5 +28,9 @@ export default async function AdminAnnouncementEditPage({ params }: PageProps) {
|
|||||||
return <Forbidden text="无效的公告 ID。" />;
|
return <Forbidden text="无效的公告 ID。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AnnouncementEditLoader id={numId} />;
|
return (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载编辑器…</p>}>
|
||||||
|
<AnnouncementEditLoader id={numId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,8 @@ export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[]
|
|||||||
{ value: "indigo", label: "靛蓝" },
|
{ value: "indigo", label: "靛蓝" },
|
||||||
{ value: "rose", label: "玫红" },
|
{ value: "rose", label: "玫红" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 公告公开路径。前台是 /announcement/[id],列表和线上查看用同一条。 */
|
||||||
|
export function announcementPublicPath(id: number): string {
|
||||||
|
return `/announcement/${id}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
@@ -17,5 +18,9 @@ export default async function AdminAnnouncementNewPage() {
|
|||||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AnnouncementComposeClient mode="create" />;
|
return (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载编辑器…</p>}>
|
||||||
|
<AnnouncementComposeClient mode="create" />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import AnnouncementAdmin from "./AnnouncementAdmin";
|
import AnnouncementAdmin from "./AnnouncementAdmin";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
@@ -28,5 +29,9 @@ export default async function AdminAnnouncementsPage() {
|
|||||||
initError = e instanceof Error ? e.message : "加载公告失败";
|
initError = e instanceof Error ? e.message : "加载公告失败";
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AnnouncementAdmin initial={initial} initError={initError} />;
|
return (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载公告列表…</p>}>
|
||||||
|
<AnnouncementAdmin initial={initial} initError={initError} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { ArrowLeft, FileText } from "lucide-react";
|
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiAdminCreateSitePage,
|
apiAdminCreateSitePage,
|
||||||
apiAdminUpdateSitePage,
|
apiAdminUpdateSitePage,
|
||||||
type SitePage,
|
type SitePage,
|
||||||
type SitePageInput,
|
type SitePageInput,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { sitePagePublicPath, sitePagePublicURL } from "@/lib/sitePagePath";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||||
import {
|
import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin";
|
||||||
AdminField,
|
|
||||||
AdminPageHeader,
|
|
||||||
AdminSegmented,
|
|
||||||
AdminSettingsActions,
|
|
||||||
AdminSettingsGroup,
|
|
||||||
AdminSettingsPage,
|
|
||||||
AdminSettingsRow,
|
|
||||||
AdminSwitch,
|
|
||||||
} from "@/components/admin";
|
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||||
|
|
||||||
type PublishKey = "published" | "draft";
|
type PublishKey = "published" | "draft";
|
||||||
|
type StatusFilter = "all" | "published" | "draft";
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -37,6 +30,16 @@ type FormState = {
|
|||||||
sort_order: number;
|
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 {
|
function toForm(p?: SitePage | null): FormState {
|
||||||
return {
|
return {
|
||||||
slug: p?.slug ?? "",
|
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 {
|
function normalizeSlug(raw: string): string {
|
||||||
return raw
|
return raw.trim().toLowerCase();
|
||||||
.trim()
|
}
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9-]+/g, "-")
|
function validate(form: FormState): FieldErrors {
|
||||||
.replace(/-+/g, "-")
|
const errors: FieldErrors = {};
|
||||||
.replace(/^-|-$/g, "");
|
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({
|
export default function PageComposeClient({
|
||||||
@@ -79,13 +125,28 @@ export default function PageComposeClient({
|
|||||||
initial?: SitePage | null;
|
initial?: SitePage | null;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const baseline = useMemo(() => toForm(initial), [initial]);
|
const searchParams = useSearchParams();
|
||||||
const [form, setForm] = useState<FormState>(baseline);
|
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 formRef = useRef(form);
|
||||||
const [saving, setSaving] = useState(false);
|
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 dirty = !sameForm(form, baseline);
|
||||||
const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !saving);
|
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)) => {
|
const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => {
|
||||||
setForm((prev) => {
|
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;
|
if (saving) return;
|
||||||
const current = formRef.current;
|
const current = formRef.current;
|
||||||
const slug = normalizeSlug(current.slug);
|
const slug = normalizeSlug(current.slug);
|
||||||
@@ -108,146 +173,314 @@ export default function PageComposeClient({
|
|||||||
show_in_sidebar: current.show_in_sidebar,
|
show_in_sidebar: current.show_in_sidebar,
|
||||||
sort_order: current.sort_order,
|
sort_order: current.sort_order,
|
||||||
};
|
};
|
||||||
if (!payload.slug || !payload.title || !payload.content) {
|
|
||||||
toast("slug、标题和内容不能为空");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setErrors({});
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
if (isEdit && savedPage) {
|
||||||
await apiAdminUpdateSitePage(initial.id, payload);
|
const res = await apiAdminUpdateSitePage(savedPage.id, payload);
|
||||||
toast("页面已更新", "ok");
|
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 {
|
} else {
|
||||||
await apiAdminCreateSitePage(payload);
|
const res = await apiAdminCreateSitePage(payload);
|
||||||
toast(payload.published ? "页面已发布" : "草稿已保存", "ok");
|
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) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
const msg = e instanceof Error ? e.message : "保存失败";
|
||||||
|
setErrors(errorsFromServer(msg));
|
||||||
|
toast(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
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 (
|
return (
|
||||||
<AdminSettingsPage className="is-fill">
|
<div className="admin-doc-page admin-doc-editor">
|
||||||
<AdminPageHeader
|
<header className="admin-doc-head">
|
||||||
icon={FileText}
|
<div className="min-w-0">
|
||||||
title={isEdit ? "编辑单页" : "新建单页"}
|
<Link href={backHref} className="admin-doc-back">
|
||||||
description="关于本站、隐私政策等 Markdown 单页;可展示在首页右栏。"
|
<ArrowLeft size={14} aria-hidden /> 返回列表
|
||||||
actions={
|
|
||||||
<Link href="/admin/pages" className="btn btn-line btn-sm">
|
|
||||||
<ArrowLeft size={14} /> 返回列表
|
|
||||||
</Link>
|
</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">
|
<div className="admin-doc-split">
|
||||||
<AdminSettingsGroup title="页面设置" description="slug 写入后即是前台地址 /p/…">
|
<div className="admin-doc-main">
|
||||||
<AdminSettingsRow label="URL slug" htmlFor="page-slug" hint="/p/ 后的路径">
|
<AdminField
|
||||||
<div className="admin-settings-control-text flex items-center gap-1.5 min-w-0">
|
id="page-title"
|
||||||
<span className="meta text-[13px] shrink-0">/p/</span>
|
label="标题"
|
||||||
<div className="admin-field-shell min-w-0 flex-1">
|
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
|
<input
|
||||||
id="page-slug"
|
id="page-slug"
|
||||||
className="admin-field-input font-mono text-[13px]"
|
className="admin-doc-slug-input"
|
||||||
maxLength={64}
|
maxLength={64}
|
||||||
value={form.slug}
|
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) }))}
|
onBlur={() => patchForm((f) => ({ ...f, slug: normalizeSlug(f.slug) }))}
|
||||||
placeholder="about"
|
placeholder="about"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</AdminSettingsRow>
|
|
||||||
<AdminSettingsRow label="摘要" htmlFor="page-excerpt" hintBelow="侧栏「关于本站」使用这一句" wide>
|
|
||||||
<AdminField
|
<AdminField
|
||||||
id="page-excerpt"
|
id="page-excerpt"
|
||||||
label="摘要"
|
label="摘要"
|
||||||
embed
|
hint="用于首页侧栏「关于本站」、关于本站导航和单页页眉。未填写时这些位置不显示简介。"
|
||||||
as="textarea"
|
as="textarea"
|
||||||
className="admin-settings-control-long"
|
|
||||||
maxLength={300}
|
maxLength={300}
|
||||||
minHeight="4.5rem"
|
minHeight="5.5rem"
|
||||||
value={form.excerpt}
|
value={form.excerpt}
|
||||||
onChange={(excerpt) => patchForm({ excerpt })}
|
error={errors.excerpt}
|
||||||
placeholder="一句话介绍本页…"
|
onChange={(excerpt) => {
|
||||||
/>
|
setErrors((prev) => ({ ...prev, excerpt: undefined }));
|
||||||
</AdminSettingsRow>
|
patchForm({ excerpt });
|
||||||
<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);
|
|
||||||
}}
|
}}
|
||||||
|
placeholder="一句话介绍本页"
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
<div className="admin-doc-field">
|
||||||
<AdminSettingsRow label="标题" htmlFor="page-title" wide>
|
<p className="admin-doc-label">展示位置</p>
|
||||||
<AdminField
|
<div className="admin-doc-switchline">
|
||||||
id="page-title"
|
<AdminSwitch
|
||||||
label="标题"
|
checked={form.show_in_sidebar}
|
||||||
embed
|
onChange={() => patchForm((f) => ({ ...f, show_in_sidebar: !f.show_in_sidebar }))}
|
||||||
maxLength={200}
|
label="显示在首页侧栏"
|
||||||
value={form.title}
|
/>
|
||||||
onChange={(title) => patchForm({ title })}
|
<span>显示在首页侧栏</span>
|
||||||
placeholder="页面标题"
|
</div>
|
||||||
/>
|
<p className="admin-doc-hint">
|
||||||
</AdminSettingsRow>
|
已发布且开启后,出现在首页右栏「关于本站」。草稿即使开启也不会出现。已发布的单页不会自动进入侧栏。
|
||||||
<AdminSettingsRow className="admin-settings-md-editor" label="正文" htmlFor="page-content" wide>
|
</p>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -259,6 +492,16 @@ export default function PageComposeClient({
|
|||||||
onCancel={cancelLeave}
|
onCancel={cancelLeave}
|
||||||
onConfirm={confirmLeave}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
Plus,
|
import { Ellipsis, ExternalLink, FileText, Plus } from "lucide-react";
|
||||||
Pencil,
|
import { apiAdminDeleteSitePage, type SitePage } from "@/lib/api";
|
||||||
Trash2,
|
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||||
Loader2,
|
import { sitePagePublicPath } from "@/lib/sitePagePath";
|
||||||
ExternalLink,
|
|
||||||
FileText,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
apiAdminDeleteSitePage,
|
|
||||||
type SitePage,
|
|
||||||
} from "@/lib/api";
|
|
||||||
import { formatRelative } from "@/lib/format";
|
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import {
|
import {
|
||||||
AdminEmpty,
|
AdminEmpty,
|
||||||
AdminIconButton,
|
|
||||||
AdminPageHeader,
|
AdminPageHeader,
|
||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
AdminSettingsPage,
|
|
||||||
AdminStatusChip,
|
AdminStatusChip,
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
|
|
||||||
type StatusFilter = "all" | "published" | "draft";
|
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({
|
export default function PagesAdmin({
|
||||||
initial,
|
initial,
|
||||||
initError,
|
initError,
|
||||||
@@ -35,11 +48,16 @@ export default function PagesAdmin({
|
|||||||
initial: SitePage[];
|
initial: SitePage[];
|
||||||
initError: string;
|
initError: string;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const statusFilter = readStatus(searchParams.get("status"));
|
||||||
const [list, setList] = useState(initial);
|
const [list, setList] = useState(initial);
|
||||||
const [loadError, setLoadError] = useState(initError);
|
const [loadError] = useState(initError);
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<SitePage | 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 publishedCount = useMemo(() => list.filter((p) => p.published).length, [list]);
|
||||||
const draftCount = 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;
|
return list;
|
||||||
}, [list, statusFilter]);
|
}, [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) {
|
async function remove(p: SitePage) {
|
||||||
setDeletingId(p.id);
|
setDeletingId(p.id);
|
||||||
try {
|
try {
|
||||||
@@ -61,17 +99,18 @@ export default function PagesAdmin({
|
|||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
|
setMenuId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminSettingsPage>
|
<div className="admin-doc-page admin-doc-list">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="单页管理"
|
title="单页管理"
|
||||||
description="关于本站、隐私政策等自定义 Markdown 单页;可展示在首页右栏"
|
description="关于本站、功能介绍等自定义页面。已发布且开启侧栏的,会出现在首页右栏。"
|
||||||
actions={
|
actions={
|
||||||
<Link href="/admin/pages/new" className="btn btn-primary">
|
<Link href={newHref(statusFilter)} className="btn btn-primary">
|
||||||
<Plus size={15} /> 新建单页
|
<Plus size={15} /> 新建单页
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
@@ -82,7 +121,7 @@ export default function PagesAdmin({
|
|||||||
ariaLabel="单页状态筛选"
|
ariaLabel="单页状态筛选"
|
||||||
size="sm"
|
size="sm"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={setStatusFilter}
|
onChange={setStatus}
|
||||||
options={[
|
options={[
|
||||||
{ key: "all", label: "全部", count: list.length },
|
{ key: "all", label: "全部", count: list.length },
|
||||||
{ key: "published", label: "已发布", count: publishedCount },
|
{ key: "published", label: "已发布", count: publishedCount },
|
||||||
@@ -91,7 +130,7 @@ export default function PagesAdmin({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel overflow-hidden min-w-0">
|
<div className="panel min-w-0">
|
||||||
<div
|
<div
|
||||||
className="px-4 sm:px-5 py-3 flex items-center justify-between"
|
className="px-4 sm:px-5 py-3 flex items-center justify-between"
|
||||||
style={{ borderBottom: "1px solid var(--line)" }}
|
style={{ borderBottom: "1px solid var(--line)" }}
|
||||||
@@ -111,88 +150,137 @@ export default function PagesAdmin({
|
|||||||
description={list.length === 0 ? "创建关于本站等内容页" : "试试切换筛选条件"}
|
description={list.length === 0 ? "创建关于本站等内容页" : "试试切换筛选条件"}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((p, idx) => (
|
<div className="admin-doc-table-wrap">
|
||||||
<div
|
<table className="admin-doc-table">
|
||||||
key={p.id}
|
<caption className="sr-only">单页列表</caption>
|
||||||
className="px-4 sm:px-5 py-3.5"
|
<thead>
|
||||||
style={{
|
<tr>
|
||||||
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
|
<th scope="col">页面</th>
|
||||||
}}
|
<th scope="col">状态</th>
|
||||||
>
|
<th scope="col">展示位置</th>
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<th scope="col">排序</th>
|
||||||
<div className="min-w-0 flex-1">
|
<th scope="col">最近更新</th>
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<th scope="col">操作</th>
|
||||||
<p
|
</tr>
|
||||||
className="text-[14px] font-medium truncate max-w-full"
|
</thead>
|
||||||
style={{ color: "var(--ink)" }}
|
<tbody>
|
||||||
data-tip={p.title}
|
{filtered.map((p) => {
|
||||||
>
|
const updated = p.updated_at || "";
|
||||||
{p.title}
|
const path = sitePagePublicPath(p.slug);
|
||||||
</p>
|
return (
|
||||||
<code className="meta text-[11.5px] tabular-nums">/{p.slug}</code>
|
<tr key={p.id}>
|
||||||
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
|
<td data-label="页面">
|
||||||
{p.published ? "已发布" : "草稿"}
|
<Link href={editHref(p.id, statusFilter)} className="admin-doc-title" title={p.title}>
|
||||||
</AdminStatusChip>
|
{p.title}
|
||||||
{p.show_in_sidebar ? (
|
</Link>
|
||||||
<AdminStatusChip tone="accent">侧栏</AdminStatusChip>
|
<p className="admin-doc-path">
|
||||||
) : null}
|
<code>{path}</code>
|
||||||
<span className="meta text-[11px] tabular-nums shrink-0">
|
</p>
|
||||||
排序 {p.sort_order} · {formatRelative(p.updated_at || p.created_at)}
|
{p.excerpt ? <p className="admin-doc-excerpt">{p.excerpt}</p> : null}
|
||||||
</span>
|
</td>
|
||||||
</div>
|
<td data-label="状态">
|
||||||
{p.excerpt ? (
|
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
|
||||||
<p className="meta text-[12.5px] mt-1 line-clamp-1">{p.excerpt}</p>
|
{p.published ? "已发布" : "草稿"}
|
||||||
) : null}
|
</AdminStatusChip>
|
||||||
</div>
|
</td>
|
||||||
<span className="inline-flex items-center gap-0.5 shrink-0">
|
<td data-label="展示位置">{placeLabel(p)}</td>
|
||||||
{p.published ? (
|
<td data-label="排序" className="tabular-nums">
|
||||||
<AdminIconButton
|
{p.sort_order}
|
||||||
href={`/p/${p.slug}`}
|
</td>
|
||||||
tip="查看前台"
|
<td data-label="最近更新">
|
||||||
ariaLabel="查看单页"
|
{updated ? (
|
||||||
>
|
<time dateTime={updated} title={formatDateTime(updated)}>
|
||||||
<ExternalLink size={14} />
|
{formatRelative(updated)}
|
||||||
</AdminIconButton>
|
</time>
|
||||||
) : null}
|
) : (
|
||||||
<AdminIconButton
|
"—"
|
||||||
href={`/admin/pages/${p.id}/edit`}
|
)}
|
||||||
tip="编辑"
|
</td>
|
||||||
ariaLabel="编辑单页"
|
<td data-label="操作">
|
||||||
>
|
<div className="admin-doc-row-actions">
|
||||||
<Pencil size={14} />
|
<Link href={editHref(p.id, statusFilter)} className="admin-doc-textbtn">
|
||||||
</AdminIconButton>
|
编辑
|
||||||
<AdminIconButton
|
</Link>
|
||||||
tip="删除"
|
{p.published ? (
|
||||||
ariaLabel="删除单页"
|
<a
|
||||||
tone="danger"
|
href={path}
|
||||||
disabled={deletingId === p.id}
|
target="_blank"
|
||||||
onClick={() => setDeleteTarget(p)}
|
rel="noopener noreferrer"
|
||||||
>
|
className="admin-doc-textbtn"
|
||||||
{deletingId === p.id ? (
|
>
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<ExternalLink size={14} aria-hidden />
|
||||||
) : (
|
查看
|
||||||
<Trash2 size={14} />
|
</a>
|
||||||
)}
|
) : null}
|
||||||
</AdminIconButton>
|
<div
|
||||||
</span>
|
className="admin-doc-more"
|
||||||
</div>
|
ref={menuId === p.id ? menuRef : undefined}
|
||||||
</div>
|
>
|
||||||
))
|
<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 ? (
|
{list.length === 0 && !loadError ? (
|
||||||
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
|
<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} /> 新建第一页
|
<Plus size={15} /> 新建第一页
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{list.length >= 100 ? (
|
||||||
|
<p className="meta text-[12px] px-4 sm:px-5 py-3">后台一次最多列出 100 条。</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title="删除单页"
|
title="删除单页"
|
||||||
message={deleteTarget ? `确定删除「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
|
message={
|
||||||
|
deleteTarget
|
||||||
|
? `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,后台也不能恢复。`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
danger
|
danger
|
||||||
busy={deletingId != null}
|
busy={deletingId != null}
|
||||||
confirmLabel="删除"
|
confirmLabel="删除"
|
||||||
@@ -201,6 +289,6 @@ export default function PagesAdmin({
|
|||||||
if (deleteTarget) void remove(deleteTarget);
|
if (deleteTarget) void remove(deleteTarget);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AdminSettingsPage>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
@@ -27,5 +28,9 @@ export default async function AdminPageEditPage({ params }: PageProps) {
|
|||||||
return <Forbidden text="无效的页面 ID。" />;
|
return <Forbidden text="无效的页面 ID。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PageEditLoader id={numId} />;
|
return (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载编辑器…</p>}>
|
||||||
|
<PageEditLoader id={numId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
@@ -17,5 +18,9 @@ export default async function AdminPageNewPage() {
|
|||||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PageComposeClient mode="create" />;
|
return (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载编辑器…</p>}>
|
||||||
|
<PageComposeClient mode="create" />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import PagesAdmin from "./PagesAdmin";
|
import PagesAdmin from "./PagesAdmin";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
@@ -27,5 +28,9 @@ export default async function AdminPagesPage() {
|
|||||||
initError = e instanceof Error ? e.message : "加载单页失败";
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4219,6 +4219,437 @@ a.j13-mention-token:hover {
|
|||||||
outline-offset: 2px;
|
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 {
|
.admin-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -7028,3 +7459,14 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
outline: none;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,6 +145,36 @@ export default function MarkdownEditor({
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
const savedSel = useRef<{ start: number; end: number } | null>(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(() => {
|
useEffect(() => {
|
||||||
if (!fullscreen) return;
|
if (!fullscreen) return;
|
||||||
@@ -152,7 +182,10 @@ export default function MarkdownEditor({
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (popover) setPopover(null);
|
if (popover) setPopover(null);
|
||||||
else setFullscreen(false);
|
else {
|
||||||
|
rememberCaret();
|
||||||
|
setFullscreen(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const prev = document.body.style.overflow;
|
const prev = document.body.style.overflow;
|
||||||
@@ -452,6 +485,8 @@ export default function MarkdownEditor({
|
|||||||
|
|
||||||
const onTextareaKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
const onTextareaKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
|
// 输入法组字期间的 Enter 不是提交
|
||||||
|
if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmitShortcut?.();
|
onSubmitShortcut?.();
|
||||||
return;
|
return;
|
||||||
@@ -954,7 +989,8 @@ export default function MarkdownEditor({
|
|||||||
className="j13-md-tool"
|
className="j13-md-tool"
|
||||||
data-tip={fullscreen ? "退出全屏 (Esc)" : "全屏写作"}
|
data-tip={fullscreen ? "退出全屏 (Esc)" : "全屏写作"}
|
||||||
aria-label={fullscreen ? "退出全屏" : "全屏写作"}
|
aria-label={fullscreen ? "退出全屏" : "全屏写作"}
|
||||||
onClick={() => setFullscreen((v) => !v)}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={toggleFullscreen}
|
||||||
>
|
>
|
||||||
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export default function useUnsavedGuard(dirty: boolean) {
|
|||||||
if (!a) return;
|
if (!a) return;
|
||||||
const href = a.getAttribute("href");
|
const href = a.getAttribute("href");
|
||||||
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("javascript:")) return;
|
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("javascript:")) return;
|
||||||
|
if (a.getAttribute("target") === "_blank") return;
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
url = new URL(href, window.location.href);
|
url = new URL(href, window.location.href);
|
||||||
|
|||||||
13
frontend/lib/sitePagePath.ts
Normal file
13
frontend/lib/sitePagePath.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user