后台单页和公告改为正文优先编辑,保存与发布分开。
列表按列对照真实路径和状态,避免把 /p/ 前缀和发布操作混在一起。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,38 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Ellipsis, ExternalLink, Loader2, Megaphone, Plus } from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Megaphone,
|
||||
Pin,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminListAnnouncements,
|
||||
apiAdminDeleteAnnouncement,
|
||||
apiAdminListAnnouncements,
|
||||
apiAdminToggleAnnouncementPin,
|
||||
type Announcement,
|
||||
} from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminIconButton,
|
||||
AdminPageHeader,
|
||||
AdminSegmented,
|
||||
AdminSettingsPage,
|
||||
AdminStatusChip,
|
||||
} from "@/components/admin";
|
||||
import { announcementPublicPath } from "./announcementColors";
|
||||
|
||||
type StatusFilter = "all" | "published" | "draft";
|
||||
|
||||
/** 粗略去掉 Markdown,取一行摘要 */
|
||||
function plainSummary(md: string, max = 96): string {
|
||||
function readStatus(raw: string | null): StatusFilter {
|
||||
if (raw === "published" || raw === "draft") return raw;
|
||||
return "all";
|
||||
}
|
||||
|
||||
function listHref(status: StatusFilter): string {
|
||||
return status === "all" ? "/admin/announcements" : `/admin/announcements?status=${status}`;
|
||||
}
|
||||
|
||||
function newHref(status: StatusFilter): string {
|
||||
return status === "all" ? "/admin/announcements/new" : `/admin/announcements/new?status=${status}`;
|
||||
}
|
||||
|
||||
function editHref(id: number, status: StatusFilter): string {
|
||||
const base = `/admin/announcements/${id}/edit`;
|
||||
return status === "all" ? base : `${base}?status=${status}`;
|
||||
}
|
||||
|
||||
/** 粗略去掉 Markdown,取一行摘要。只用于列表预览,不改库存正文。 */
|
||||
function plainSummary(md: string, max = 80): string {
|
||||
const text = md
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
|
||||
@@ -50,12 +60,17 @@ export default function AnnouncementAdmin({
|
||||
initial: Announcement[];
|
||||
initError: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const statusFilter = readStatus(searchParams.get("status"));
|
||||
const [list, setList] = useState(initial);
|
||||
const [loadError, setLoadError] = useState(initError);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [pinningId, setPinningId] = useState<number | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | 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 draftCount = useMemo(() => list.filter((a) => !a.published).length, [list]);
|
||||
@@ -76,14 +91,32 @@ export default function AnnouncementAdmin({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuId == null) return;
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuId(null);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMenuId(null);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointer);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointer);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [menuId]);
|
||||
|
||||
function setStatus(next: StatusFilter) {
|
||||
router.replace(listHref(next), { scroll: false });
|
||||
}
|
||||
|
||||
async function togglePin(a: Announcement) {
|
||||
if (pinningId != null) return;
|
||||
setPinningId(a.id);
|
||||
try {
|
||||
const res = await apiAdminToggleAnnouncementPin(a.id);
|
||||
setList((prev) => prev.map((x) => (x.id === a.id ? res.announcement : x)));
|
||||
toast(res.announcement.pinned ? "已置顶" : "已取消置顶", "ok");
|
||||
// 置顶后列表顺序可能变化,重新拉一次对齐后端排序
|
||||
await apiAdminToggleAnnouncementPin(a.id);
|
||||
toast(a.pinned ? "已取消置顶" : "已置顶", "ok");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "置顶失败");
|
||||
@@ -103,17 +136,18 @@ export default function AnnouncementAdmin({
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setDeleteTarget(null);
|
||||
setMenuId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminSettingsPage>
|
||||
<div className="admin-doc-page admin-doc-list">
|
||||
<AdminPageHeader
|
||||
icon={Megaphone}
|
||||
title="站点公告管理"
|
||||
description="发布、编辑与下线右栏站点公告,支持置顶与自定义标签色"
|
||||
description="已发布的公告出现在首页右栏,置顶的排在前面。"
|
||||
actions={
|
||||
<Link href="/admin/announcements/new" className="btn btn-primary">
|
||||
<Link href={newHref(statusFilter)} className="btn btn-primary">
|
||||
<Plus size={15} /> 新建公告
|
||||
</Link>
|
||||
}
|
||||
@@ -124,7 +158,7 @@ export default function AnnouncementAdmin({
|
||||
ariaLabel="公告状态筛选"
|
||||
size="sm"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
onChange={setStatus}
|
||||
options={[
|
||||
{ key: "all", label: "全部", count: list.length },
|
||||
{ key: "published", label: "已发布", count: publishedCount },
|
||||
@@ -133,7 +167,7 @@ export default function AnnouncementAdmin({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel overflow-hidden min-w-0">
|
||||
<div className="panel min-w-0">
|
||||
<div
|
||||
className="px-4 sm:px-5 py-3 flex items-center justify-between"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
@@ -155,108 +189,141 @@ export default function AnnouncementAdmin({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
filtered.map((a, idx) => {
|
||||
const summary = plainSummary(a.content);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className="px-4 sm:px-5 py-3.5"
|
||||
style={{
|
||||
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<span
|
||||
className={`j13-anno-dot is-${a.tag_color} mt-1.5 shrink-0`}
|
||||
aria-hidden
|
||||
data-tip={a.tag}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{a.pinned ? (
|
||||
<span className="badge-pin shrink-0">置顶</span>
|
||||
) : null}
|
||||
<p
|
||||
className="text-[14px] font-medium truncate max-w-full"
|
||||
style={{ color: "var(--ink)" }}
|
||||
data-tip={a.title}
|
||||
>
|
||||
{a.title}
|
||||
</p>
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
|
||||
{a.published ? "已发布" : "草稿"}
|
||||
</AdminStatusChip>
|
||||
<span className="meta text-[11px] tabular-nums shrink-0">
|
||||
{formatRelative(a.updated_at || a.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{summary ? (
|
||||
<p className="meta text-[12.5px] mt-1 line-clamp-1">{summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-0.5 shrink-0">
|
||||
<AdminIconButton
|
||||
tip={a.pinned ? "取消置顶" : "置顶"}
|
||||
ariaLabel={a.pinned ? "取消置顶" : "置顶公告"}
|
||||
disabled={pinningId === a.id}
|
||||
onClick={() => void togglePin(a)}
|
||||
>
|
||||
{pinningId === a.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Pin size={14} style={{ color: a.pinned ? "var(--accent)" : undefined }} />
|
||||
)}
|
||||
</AdminIconButton>
|
||||
{a.published ? (
|
||||
<AdminIconButton
|
||||
href={`/announcement/${a.id}`}
|
||||
tip="查看前台"
|
||||
ariaLabel="查看公告"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</AdminIconButton>
|
||||
) : null}
|
||||
<AdminIconButton
|
||||
href={`/admin/announcements/${a.id}/edit`}
|
||||
tip="编辑"
|
||||
ariaLabel="编辑公告"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</AdminIconButton>
|
||||
<AdminIconButton
|
||||
tip="删除"
|
||||
ariaLabel="删除公告"
|
||||
tone="danger"
|
||||
disabled={deletingId === a.id}
|
||||
onClick={() => setDeleteTarget(a)}
|
||||
>
|
||||
{deletingId === a.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</AdminIconButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
<div className="admin-doc-table-wrap">
|
||||
<table className="admin-doc-table">
|
||||
<caption className="sr-only">公告列表</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">公告</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">置顶</th>
|
||||
<th scope="col">最近更新</th>
|
||||
<th scope="col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((a) => {
|
||||
const summary = plainSummary(a.content);
|
||||
const updated = a.updated_at || "";
|
||||
return (
|
||||
<tr key={a.id}>
|
||||
<td data-label="公告">
|
||||
<Link href={editHref(a.id, statusFilter)} className="admin-doc-title" title={a.title}>
|
||||
{a.title}
|
||||
</Link>
|
||||
<p className="admin-doc-path">
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`} title={a.tag}>
|
||||
{a.tag}
|
||||
</span>
|
||||
{a.published ? <code>{announcementPublicPath(a.id)}</code> : null}
|
||||
</p>
|
||||
{summary ? <p className="admin-doc-excerpt" title={summary}>{summary}</p> : null}
|
||||
</td>
|
||||
<td data-label="状态">
|
||||
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
|
||||
{a.published ? "已发布" : "草稿"}
|
||||
</AdminStatusChip>
|
||||
</td>
|
||||
<td data-label="置顶">{a.pinned ? "置顶" : "未置顶"}</td>
|
||||
<td data-label="最近更新">
|
||||
{updated ? (
|
||||
<time dateTime={updated} title={formatDateTime(updated)}>
|
||||
{formatRelative(updated)}
|
||||
</time>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td data-label="操作">
|
||||
<div className="admin-doc-row-actions">
|
||||
<Link href={editHref(a.id, statusFilter)} className="admin-doc-textbtn">
|
||||
编辑
|
||||
</Link>
|
||||
{a.published ? (
|
||||
<a
|
||||
href={announcementPublicPath(a.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-doc-textbtn"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden />
|
||||
查看
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="admin-doc-textbtn"
|
||||
disabled={pinningId === a.id}
|
||||
onClick={() => void togglePin(a)}
|
||||
>
|
||||
{pinningId === a.id ? (
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{a.pinned ? "取消置顶" : "置顶"}
|
||||
</button>
|
||||
<div className="admin-doc-more" ref={menuId === a.id ? menuRef : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-doc-textbtn"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuId === a.id}
|
||||
aria-label={`「${a.title}」的更多操作`}
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMenuUp(rect.bottom > window.innerHeight - 140);
|
||||
setMenuId((cur) => (cur === a.id ? null : a.id));
|
||||
}}
|
||||
>
|
||||
<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 ? (
|
||||
<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} /> 新建第一条公告
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{list.length >= 100 ? (
|
||||
<p className="meta text-[12px] px-4 sm:px-5 py-3">后台一次最多列出 100 条。</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="删除公告"
|
||||
message={deleteTarget ? `确定删除公告「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
|
||||
message={
|
||||
deleteTarget
|
||||
? `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,后台也不能恢复。`
|
||||
: ""
|
||||
}
|
||||
danger
|
||||
busy={deletingId != null}
|
||||
confirmLabel="删除"
|
||||
@@ -265,6 +332,6 @@ export default function AnnouncementAdmin({
|
||||
if (deleteTarget) void remove(deleteTarget);
|
||||
}}
|
||||
/>
|
||||
</AdminSettingsPage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user