后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
207 lines
6.9 KiB
TypeScript
207 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import {
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
Loader2,
|
|
ExternalLink,
|
|
FileText,
|
|
} from "lucide-react";
|
|
import {
|
|
apiAdminDeleteSitePage,
|
|
type SitePage,
|
|
} from "@/lib/api";
|
|
import { formatRelative } from "@/lib/format";
|
|
import { toast } from "@/lib/toast";
|
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
|
import {
|
|
AdminEmpty,
|
|
AdminIconButton,
|
|
AdminPageHeader,
|
|
AdminSegmented,
|
|
AdminSettingsPage,
|
|
AdminStatusChip,
|
|
} from "@/components/admin";
|
|
|
|
type StatusFilter = "all" | "published" | "draft";
|
|
|
|
export default function PagesAdmin({
|
|
initial,
|
|
initError,
|
|
}: {
|
|
initial: SitePage[];
|
|
initError: string;
|
|
}) {
|
|
const [list, setList] = useState(initial);
|
|
const [loadError, setLoadError] = useState(initError);
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<SitePage | null>(null);
|
|
|
|
const publishedCount = useMemo(() => list.filter((p) => p.published).length, [list]);
|
|
const draftCount = useMemo(() => list.filter((p) => !p.published).length, [list]);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (statusFilter === "published") return list.filter((p) => p.published);
|
|
if (statusFilter === "draft") return list.filter((p) => !p.published);
|
|
return list;
|
|
}, [list, statusFilter]);
|
|
|
|
async function remove(p: SitePage) {
|
|
setDeletingId(p.id);
|
|
try {
|
|
await apiAdminDeleteSitePage(p.id);
|
|
setList((l) => l.filter((x) => x.id !== p.id));
|
|
toast("页面已删除", "ok");
|
|
} catch (e) {
|
|
toast(e instanceof Error ? e.message : "删除失败");
|
|
} finally {
|
|
setDeletingId(null);
|
|
setDeleteTarget(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AdminSettingsPage>
|
|
<AdminPageHeader
|
|
icon={FileText}
|
|
title="单页管理"
|
|
description="关于本站、隐私政策等自定义 Markdown 单页;可展示在首页右栏"
|
|
actions={
|
|
<Link href="/admin/pages/new" className="btn btn-primary">
|
|
<Plus size={15} /> 新建单页
|
|
</Link>
|
|
}
|
|
/>
|
|
|
|
<div className="mb-4">
|
|
<AdminSegmented<StatusFilter>
|
|
ariaLabel="单页状态筛选"
|
|
size="sm"
|
|
value={statusFilter}
|
|
onChange={setStatusFilter}
|
|
options={[
|
|
{ key: "all", label: "全部", count: list.length },
|
|
{ key: "published", label: "已发布", count: publishedCount },
|
|
{ key: "draft", label: "草稿", count: draftCount },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<div className="panel overflow-hidden 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={<FileText size={22} />}
|
|
title={list.length === 0 ? "还没有单页" : "该状态下暂无单页"}
|
|
description={list.length === 0 ? "创建关于本站等内容页" : "试试切换筛选条件"}
|
|
/>
|
|
) : (
|
|
filtered.map((p, idx) => (
|
|
<div
|
|
key={p.id}
|
|
className="px-4 sm:px-5 py-3.5"
|
|
style={{
|
|
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-3 min-w-0">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<p
|
|
className="text-[14px] font-medium truncate max-w-full"
|
|
style={{ color: "var(--ink)" }}
|
|
data-tip={p.title}
|
|
>
|
|
{p.title}
|
|
</p>
|
|
<code className="meta text-[11.5px] tabular-nums">/{p.slug}</code>
|
|
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
|
|
{p.published ? "已发布" : "草稿"}
|
|
</AdminStatusChip>
|
|
{p.show_in_sidebar ? (
|
|
<AdminStatusChip tone="accent">侧栏</AdminStatusChip>
|
|
) : null}
|
|
<span className="meta text-[11px] tabular-nums shrink-0">
|
|
排序 {p.sort_order} · {formatRelative(p.updated_at || p.created_at)}
|
|
</span>
|
|
</div>
|
|
{p.excerpt ? (
|
|
<p className="meta text-[12.5px] mt-1 line-clamp-1">{p.excerpt}</p>
|
|
) : null}
|
|
</div>
|
|
<span className="inline-flex items-center gap-0.5 shrink-0">
|
|
{p.published ? (
|
|
<AdminIconButton
|
|
href={`/p/${p.slug}`}
|
|
tip="查看前台"
|
|
ariaLabel="查看单页"
|
|
>
|
|
<ExternalLink size={14} />
|
|
</AdminIconButton>
|
|
) : null}
|
|
<AdminIconButton
|
|
href={`/admin/pages/${p.id}/edit`}
|
|
tip="编辑"
|
|
ariaLabel="编辑单页"
|
|
>
|
|
<Pencil size={14} />
|
|
</AdminIconButton>
|
|
<AdminIconButton
|
|
tip="删除"
|
|
ariaLabel="删除单页"
|
|
tone="danger"
|
|
disabled={deletingId === p.id}
|
|
onClick={() => setDeleteTarget(p)}
|
|
>
|
|
{deletingId === p.id ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Trash2 size={14} />
|
|
)}
|
|
</AdminIconButton>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
{list.length === 0 && !loadError ? (
|
|
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
|
|
<Link href="/admin/pages/new" className="btn btn-primary">
|
|
<Plus size={15} /> 新建第一页
|
|
</Link>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
title="删除单页"
|
|
message={deleteTarget ? `确定删除「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
|
|
danger
|
|
busy={deletingId != null}
|
|
confirmLabel="删除"
|
|
onCancel={() => deletingId == null && setDeleteTarget(null)}
|
|
onConfirm={() => {
|
|
if (deleteTarget) void remove(deleteTarget);
|
|
}}
|
|
/>
|
|
</AdminSettingsPage>
|
|
);
|
|
}
|