- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
"use client";
|
||
|
||
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 {
|
||
apiAdminDeleteAnnouncement,
|
||
apiAdminListAnnouncements,
|
||
apiAdminToggleAnnouncementPin,
|
||
type Announcement,
|
||
} from "@/lib/api";
|
||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||
import { toast } from "@/lib/toast";
|
||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||
import {
|
||
AdminEmpty,
|
||
AdminPageHeader,
|
||
AdminSegmented,
|
||
AdminStatusChip,
|
||
} from "@/components/admin";
|
||
import { announcementPublicPath } from "./announcementColors";
|
||
|
||
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/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, " ")
|
||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||
.replace(/[#>*_`~\-|=]/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||
}
|
||
|
||
export default function AnnouncementAdmin({
|
||
initial,
|
||
initError,
|
||
}: {
|
||
initial: Announcement[];
|
||
initError: string;
|
||
}) {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const urlStyle = useUrlStyle();
|
||
const statusFilter = readStatus(searchParams.get("status"));
|
||
const [list, setList] = useState(initial);
|
||
const [loadError, setLoadError] = useState(initError);
|
||
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]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (statusFilter === "published") return list.filter((a) => a.published);
|
||
if (statusFilter === "draft") return list.filter((a) => !a.published);
|
||
return list;
|
||
}, [list, statusFilter]);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const res = await apiAdminListAnnouncements();
|
||
setList(res.announcements);
|
||
setLoadError("");
|
||
} catch (e) {
|
||
setLoadError(e instanceof Error ? e.message : "加载公告失败");
|
||
}
|
||
}, []);
|
||
|
||
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 {
|
||
await apiAdminToggleAnnouncementPin(a.id);
|
||
toast(a.pinned ? "已取消置顶" : "已置顶", "ok");
|
||
await load();
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "置顶失败");
|
||
} finally {
|
||
setPinningId(null);
|
||
}
|
||
}
|
||
|
||
async function remove(a: Announcement) {
|
||
setDeletingId(a.id);
|
||
try {
|
||
await apiAdminDeleteAnnouncement(a.id);
|
||
setList((l) => l.filter((x) => x.id !== a.id));
|
||
toast("公告已删除", "ok");
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "删除失败");
|
||
} finally {
|
||
setDeletingId(null);
|
||
setDeleteTarget(null);
|
||
setMenuId(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="admin-doc-page admin-doc-list">
|
||
<AdminPageHeader
|
||
icon={Megaphone}
|
||
title="站点公告管理"
|
||
description="已发布的公告出现在首页右栏,置顶的排在前面。"
|
||
actions={
|
||
<Link href={newHref(statusFilter)} className="btn btn-primary">
|
||
<Plus size={15} /> 新建公告
|
||
</Link>
|
||
}
|
||
/>
|
||
|
||
<div className="mb-4">
|
||
<AdminSegmented<StatusFilter>
|
||
ariaLabel="公告状态筛选"
|
||
size="sm"
|
||
value={statusFilter}
|
||
onChange={setStatus}
|
||
options={[
|
||
{ key: "all", label: "全部", count: list.length },
|
||
{ key: "published", label: "已发布", count: publishedCount },
|
||
{ key: "draft", label: "草稿", count: draftCount },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<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)" }}
|
||
>
|
||
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
|
||
公告列表
|
||
</span>
|
||
<span className="meta text-[12px] tabular-nums">共 {filtered.length} 条</span>
|
||
</div>
|
||
|
||
{loadError ? (
|
||
<p className="alert-error m-4">{loadError}</p>
|
||
) : filtered.length === 0 ? (
|
||
<AdminEmpty
|
||
icon={<Megaphone size={22} />}
|
||
title={list.length === 0 ? "还没有公告" : "该状态下暂无公告"}
|
||
description={
|
||
list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"
|
||
}
|
||
/>
|
||
) : (
|
||
<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, urlStyle)}</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, urlStyle)}
|
||
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={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}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,后台也不能恢复。`
|
||
: ""
|
||
}
|
||
danger
|
||
busy={deletingId != null}
|
||
confirmLabel="删除"
|
||
onCancel={() => deletingId == null && setDeleteTarget(null)}
|
||
onConfirm={() => {
|
||
if (deleteTarget) void remove(deleteTarget);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|