feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -100,13 +100,7 @@ export default async function AboutIndexPage() {
const right = <SiteDocAnnoNav items={announcements} />;
return (
<SiteDocShell
left={left}
right={right}
mobileExtras={
announcements.length > 0 ? <SiteDocAnnoNav items={announcements} /> : null
}
>
<SiteDocShell left={left} right={right}>
<div className="panel overflow-hidden">
<header
className="px-5 sm:px-6 pt-5 pb-4"

View File

@@ -40,6 +40,34 @@ function kindBadge(kind: string) {
);
}
/** 日期筛选:复用搜索框壳层样式,与工具条同高同视觉 */
function DateField({
label,
value,
onChange,
ariaLabel,
}: {
label: string;
value: string;
onChange: (v: string) => void;
ariaLabel: string;
}) {
return (
<label className="admin-field-shell admin-search-shell w-full sm:w-44">
<span className="shrink-0 text-[12px]" style={{ color: "var(--ink-3)" }}>
{label}
</span>
<input
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className="admin-field-input"
aria-label={ariaLabel}
/>
</label>
);
}
function DetailsInner() {
const searchParams = useSearchParams();
// 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x)
@@ -86,6 +114,17 @@ function DetailsInner() {
const resetPage = () => setPage(1);
const hasFilter = Boolean(kind || path || ip || bot || start || end);
const resetAll = () => {
setKind("");
setPath("");
setIp("");
setBot("");
setStart("");
setEnd("");
setPage(1);
};
return (
<div className="flex flex-col gap-4">
<p className="meta text-[12.5px]">
@@ -106,48 +145,38 @@ function DetailsInner() {
ariaLabel="按路径筛选"
/>
</div>
<div className="w-full sm:w-44">
<div className="w-full sm:w-48">
<AdminSearchField
value={ip}
onChange={setIp}
onSubmit={resetPage}
onClear={() => { setIp(""); resetPage(); }}
placeholder="按 IP 筛选,回车应用"
placeholder="按 IP 精确筛选"
ariaLabel="按 IP 筛选"
/>
</div>
<div className="w-full sm:w-40">
<div className="w-full sm:w-44">
<AdminSearchField
value={bot}
onChange={setBot}
onSubmit={resetPage}
onClear={() => { setBot(""); resetPage(); }}
placeholder="按爬虫名筛选"
placeholder="按爬虫名精确筛选"
ariaLabel="按爬虫名筛选"
/>
</div>
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
从
<input
type="date"
value={start}
onChange={(e) => { setStart(e.target.value); resetPage(); }}
className="admin-field-input"
style={{ width: 150 }}
aria-label="开始日期"
/>
</label>
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
至
<input
type="date"
value={end}
onChange={(e) => { setEnd(e.target.value); resetPage(); }}
className="admin-field-input"
style={{ width: 150 }}
aria-label="结束日期"
/>
</label>
<DateField label="从" value={start} onChange={(v) => { setStart(v); resetPage(); }} ariaLabel="开始日期" />
<DateField label="至" value={end} onChange={(v) => { setEnd(v); resetPage(); }} ariaLabel="结束日期" />
{hasFilter ? (
<button
type="button"
onClick={resetAll}
className="admin-field-shell admin-search-shell w-full sm:w-auto justify-center text-[13px] cursor-pointer transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)" }}
>
重置筛选
</button>
) : null}
</div>
</div>

View File

@@ -0,0 +1,493 @@
"use client";
import { useMemo, useState } from "react";
import {
Check,
Link2,
Loader2,
Pencil,
Plus,
RotateCcw,
Trash2,
X,
} from "lucide-react";
import {
apiAdminCreateFriendLink,
apiAdminDeleteFriendLink,
apiAdminListFriendLinks,
apiAdminSetFriendLinkStatus,
apiAdminUpdateFriendLink,
type FriendLink,
type FriendLinkAdminInput,
type FriendLinkStatus,
} from "@/lib/api";
import { AdminPageHeader, AdminSettingsPage, AdminEmpty } from "@/components/admin";
interface Props {
initialLinks: FriendLink[];
initError: string;
}
const FIELD_CLASS = "w-full rounded-lg px-3 py-2 text-[14px] outline-none transition-colors";
const FIELD_STYLE = {
background: "var(--panel-2)",
color: "var(--ink)",
border: "1px solid var(--line)",
};
const TABS: { key: FriendLinkStatus; label: string }[] = [
{ key: "pending", label: "待审批" },
{ key: "approved", label: "已通过" },
{ key: "rejected", label: "已拒绝" },
];
interface FormState {
name: string;
url: string;
description: string;
logo_url: string;
category: string;
reciprocal_url: string;
sort_order: number;
}
const EMPTY_FORM: FormState = {
name: "",
url: "",
description: "",
logo_url: "",
category: "",
reciprocal_url: "",
sort_order: 0,
};
export default function FriendLinksAdmin({ initialLinks, initError }: Props) {
const [links, setLinks] = useState<FriendLink[]>(initialLinks);
const [tab, setTab] = useState<FriendLinkStatus>("pending");
const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState(initError);
const counts = useMemo(() => {
const c = { pending: 0, approved: 0, rejected: 0 } as Record<FriendLinkStatus, number>;
for (const l of links) c[l.status] = (c[l.status] || 0) + 1;
return c;
}, [links]);
const filtered = useMemo(
() => links.filter((l) => l.status === tab).sort((a, b) => a.sort_order - b.sort_order || a.id - b.id),
[links, tab]
);
const refresh = async () => {
try {
const res = await apiAdminListFriendLinks();
setLinks(res.links ?? []);
setError("");
} catch (e) {
setError(e instanceof Error ? e.message : "加载失败");
}
};
const openCreate = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setEditorOpen(true);
};
const openEdit = (l: FriendLink) => {
setEditingId(l.id);
setForm({
name: l.name,
url: l.url,
description: l.description || "",
logo_url: l.logo_url || "",
category: l.category || "",
reciprocal_url: l.reciprocal_url || "",
sort_order: l.sort_order,
});
setEditorOpen(true);
};
const closeEditor = () => {
setEditorOpen(false);
setEditingId(null);
setForm(EMPTY_FORM);
};
const set = (k: keyof FormState, v: string | number) =>
setForm((f) => ({ ...f, [k]: v }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (saving) return;
setSaving(true);
setError("");
try {
const body: FriendLinkAdminInput = {
name: form.name.trim(),
url: form.url.trim(),
description: form.description.trim() || undefined,
logo_url: form.logo_url.trim() || undefined,
category: form.category.trim() || undefined,
reciprocal_url: form.reciprocal_url.trim() || undefined,
sort_order: form.sort_order,
};
if (editingId == null) {
await apiAdminCreateFriendLink(body);
} else {
await apiAdminUpdateFriendLink(editingId, body);
}
closeEditor();
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
};
const setStatus = async (id: number, status: FriendLinkStatus) => {
let rejectReason = "";
if (status === "rejected") {
const r = window.prompt("请输入拒绝理由(可选)", "");
if (r === null) return;
rejectReason = r;
}
setBusyId(id);
setError("");
try {
await apiAdminSetFriendLinkStatus(id, status, rejectReason || undefined);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "操作失败");
} finally {
setBusyId(null);
}
};
const remove = async (id: number) => {
if (!window.confirm("确定删除该友情链接?此操作不可撤销。")) return;
setBusyId(id);
setError("");
try {
await apiAdminDeleteFriendLink(id);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败");
} finally {
setBusyId(null);
}
};
return (
<AdminSettingsPage>
<AdminPageHeader
icon={Link2}
title="友情链接管理"
description="审批用户申请的友情链接,或直接新建/编辑已收录条目"
actions={
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
<Plus size={14} /> 新建
</button>
}
/>
{error ? <p className="alert-error mb-4">{error}</p> : null}
<div className="panel overflow-hidden">
<div className="flex items-center gap-1 px-4 pt-3 border-b" style={{ borderColor: "var(--line)" }}>
{TABS.map((t) => {
const active = tab === t.key;
const n = counts[t.key] || 0;
return (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className="px-3 py-2 text-[13.5px] font-medium transition-colors"
style={{
color: active ? "var(--accent)" : "var(--ink-3)",
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
marginBottom: "-1px",
}}
>
{t.label}
<span className="ml-1.5 text-[11.5px] opacity-70">{n}</span>
</button>
);
})}
</div>
{filtered.length === 0 ? (
<AdminEmpty
icon={<Link2 size={20} />}
title={tab === "pending" ? "暂无待审批申请" : tab === "approved" ? "暂无已收录友链" : "暂无被拒绝的申请"}
description={tab === "pending" ? "用户提交的申请会出现在这里" : undefined}
/>
) : (
<ul>
{filtered.map((l) => (
<li
key={l.id}
className="px-5 py-4 flex items-start gap-3"
style={{ borderTop: "1px solid var(--line)" }}
>
{l.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
alt=""
width={36}
height={36}
className="rounded-lg object-contain shrink-0"
style={{ width: 36, height: 36, background: "var(--panel-2)" }}
/>
) : (
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Link2 size={16} />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<a
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-[14.5px] hover:text-[var(--accent)]"
style={{ color: "var(--ink)" }}
>
{l.name}
</a>
{l.category ? (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
{l.category}
</span>
) : null}
<span className="meta text-[11.5px]">#{l.id} · 顺序 {l.sort_order}</span>
</div>
{l.description ? (
<p className="meta mt-1 text-[12.5px] line-clamp-2">{l.description}</p>
) : null}
{l.reciprocal_url ? (
<p className="meta mt-1 text-[11.5px]">
回链:
<a href={l.reciprocal_url} target="_blank" rel="noopener noreferrer" className="underline hover:text-[var(--accent)]">
{l.reciprocal_url}
</a>
</p>
) : null}
{l.status === "rejected" && l.reject_reason ? (
<p className="mt-1 text-[12px]" style={{ color: "var(--danger)" }}>拒绝理由:{l.reject_reason}</p>
) : null}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{busyId === l.id ? (
<Loader2 size={15} className="animate-spin" style={{ color: "var(--ink-3)" }} />
) : null}
{l.status === "pending" ? (
<>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => setStatus(l.id, "approved")}
disabled={busyId === l.id}
title="通过"
>
<Check size={13} /> 通过
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setStatus(l.id, "rejected")}
disabled={busyId === l.id}
title="拒绝"
>
<X size={13} /> 拒绝
</button>
</>
) : null}
{l.status === "rejected" ? (
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setStatus(l.id, "approved")}
disabled={busyId === l.id}
title="重新通过"
>
<RotateCcw size={13} /> 恢复
</button>
) : null}
{l.status === "approved" ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => openEdit(l)}
disabled={busyId === l.id}
title="编辑"
>
<Pencil size={13} /> 编辑
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setStatus(l.id, "pending")}
disabled={busyId === l.id}
title="下线"
>
下线
</button>
</>
) : null}
{l.status === "pending" ? (
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => openEdit(l)}
disabled={busyId === l.id}
title="编辑"
>
<Pencil size={13} />
</button>
) : null}
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => remove(l.id)}
disabled={busyId === l.id}
title="删除"
style={{ color: "var(--danger)" }}
>
<Trash2 size={13} />
</button>
</div>
</li>
))}
</ul>
)}
</div>
{editorOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.5)" }}
onClick={closeEditor}
>
<div
className="panel w-full max-w-lg p-5 sm:p-6 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-[17px] font-bold mb-4" style={{ color: "var(--ink)" }}>
{editingId == null ? "新建友情链接" : "编辑友情链接"}
</h2>
<form onSubmit={submit} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="meta block mb-1">名称 *</span>
<input
required
maxLength={64}
value={form.name}
onChange={(e) => set("name", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
<label className="block">
<span className="meta block mb-1">地址 *</span>
<input
required
type="url"
maxLength={512}
value={form.url}
onChange={(e) => set("url", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
</div>
<label className="block">
<span className="meta block mb-1">简介</span>
<input
maxLength={255}
value={form.description}
onChange={(e) => set("description", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="meta block mb-1">Logo URL</span>
<input
type="url"
maxLength={512}
value={form.logo_url}
onChange={(e) => set("logo_url", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
<label className="block">
<span className="meta block mb-1">分类</span>
<input
maxLength={32}
value={form.category}
onChange={(e) => set("category", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="meta block mb-1">回链地址</span>
<input
type="url"
maxLength={512}
value={form.reciprocal_url}
onChange={(e) => set("reciprocal_url", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
<label className="block">
<span className="meta block mb-1">顺序</span>
<input
type="number"
min={0}
value={form.sort_order}
onChange={(e) => set("sort_order", Number(e.target.value))}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button type="button" className="btn btn-line btn-sm" onClick={closeEditor} disabled={saving}>
取消
</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? <Loader2 size={13} className="animate-spin" /> : null}
{saving ? "保存中…" : "保存"}
</button>
</div>
</form>
</div>
</div>
) : null}
</AdminSettingsPage>
);
}

View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { cookies } from "next/headers";
import FriendLinksAdmin from "./FriendLinksAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminFriendLinks, type FriendLink } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
title: "友情链接管理",
};
export default async function AdminFriendLinksPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isSuperOrOwner(me.user.role)) {
return <Forbidden text="友情链接管理仅超级管理员及以上角色可操作。" />;
}
let links: FriendLink[] = [];
let initError = "";
try {
const res = await fetchAdminFriendLinks(cookie || undefined);
links = res.links ?? [];
} catch (e) {
initError = e instanceof Error ? e.message : "加载失败";
}
return (
<Suspense fallback={<p className="meta px-1 py-6">加载中…</p>}>
<FriendLinksAdmin initialLinks={links} initError={initError} />
</Suspense>
);
}

View File

@@ -0,0 +1,431 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { FileText, Loader2, Upload, X } from "lucide-react";
import Modal from "@/components/Modal";
import { toast } from "@/lib/toast";
import type { LibraryImportMdInput, LibrarySectionItem } from "@/lib/api";
import { parseEpub } from "./parseEpub";
const MAX_IMPORT_BYTES = 5 * 1024 * 1024; // 与后端 MaxImportBytes 一致(单文件)
const MAX_TOTAL_BYTES = 20 * 1024 * 1024; // 与后端 MaxImportTotalBytes 一致
const MAX_FILES = 50; // 与后端 MaxImportFiles 一致
const MAX_EPUB_BYTES = 50 * 1024 * 1024; // epub 原始文件上限(二进制,图片占大头,转出文本通常很小)
const MAX_CHAPTER_CHARS = 100_000; // 与后端 MaxSectionContent 一致(epub 每目录项即一章)
type SplitLevel = "h1" | "h2" | "none";
const SPLIT_OPTIONS: { value: SplitLevel; label: string; hint: string }[] = [
{ value: "h1", label: "按一级标题(#)", hint: "每个 # 标题拆为一章" },
{ value: "h2", label: "按二级标题(##)", hint: "每个 ## 标题拆为一章" },
{ value: "none", label: "不拆分", hint: "每个文件整篇一章" },
];
interface PendingFile {
name: string;
text: string;
}
/** 与后端 splitMarkdownChapters 逻辑一致:跳过代码围栏,按所选级别标题取章标题列表 */
function splitPreview(content: string, level: SplitLevel): string[] {
if (level === "none") return [];
const prefix = level === "h2" ? "## " : "# ";
const titles: string[] = [];
let inCode = false;
let count = 0;
for (const raw of content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
const t = raw.trim();
if (t.startsWith("```")) inCode = !inCode;
if (!inCode && t.startsWith(prefix) && t.length > prefix.length) {
const title = t.slice(prefix.length).replace(/#+\s*$/, "").trim();
titles.push(title || `第 ${++count} 章`);
}
}
return titles;
}
/** 单个文件预计拆出的章数(未命中标题回退 1 章,标题取文件名) */
function splitCount(text: string, level: SplitLevel): number {
if (level === "none") return 1;
const n = splitPreview(text, level).length;
return n > 0 ? n : 1;
}
export default function ImportMdDialog({
onImport,
onClose,
}: {
/** 执行导入,返回新建章节(父组件负责刷新与选中第一章);失败时抛错 */
onImport: (input: LibraryImportMdInput) => Promise<LibrarySectionItem[]>;
/** 关闭弹窗;导入成功时带出新建章节,父组件据此选中第一章 */
onClose: (created?: LibrarySectionItem[]) => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<PendingFile[]>([]);
const [pasted, setPasted] = useState("");
const [splitLevel, setSplitLevel] = useState<SplitLevel>("h2");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// epub 模式:列表已被 epub 解析结果整体替换,按目录顺序每项一章(不参与拆分)
const [fromEpub, setFromEpub] = useState(false);
const [epubBusy, setEpubBusy] = useState(false);
// 已选文件优先;未选文件时用粘贴内容(视为一个伪文件)
const sourceFiles = useMemo<PendingFile[]>(
() =>
files.length > 0
? files
: pasted.trim()
? [{ name: "未命名导入.md", text: pasted }]
: [],
[files, pasted]
);
const perFileCounts = useMemo(
() => sourceFiles.map((f) => ({ name: f.name, count: splitCount(f.text, splitLevel) })),
[sourceFiles, splitLevel]
);
const totalChapters = perFileCounts.reduce((n, f) => n + f.count, 0);
const single = sourceFiles.length === 1 ? sourceFiles[0] : null;
const singleTitles = useMemo(
() => (single && splitLevel !== "none" ? splitPreview(single.text, splitLevel) : []),
[single, splitLevel]
);
const willFallback =
single != null && splitLevel !== "none" && singleTitles.length === 0 && single.text.trim().length > 0;
const oversizeFile = sourceFiles.find((f) => f.text.length > MAX_IMPORT_BYTES);
const overFiles = sourceFiles.length > MAX_FILES;
const overTotal = sourceFiles.reduce((n, f) => n + f.text.length, 0) > MAX_TOTAL_BYTES;
// epub 每个目录项即一章,无法再拆;超限章直接阻止导入(与后端校验一致)
const oversizeChapters = fromEpub
? sourceFiles.filter((f) => f.text.length > MAX_CHAPTER_CHARS)
: [];
async function pickFiles(list: FileList | null) {
if (!list || list.length === 0) return;
setError("");
const msgs: string[] = [];
const fileList = Array.from(list);
const epubFiles = fileList.filter((f) => f.name.toLowerCase().endsWith(".epub"));
const mdFiles = fileList.filter((f) => !f.name.toLowerCase().endsWith(".epub"));
// epub:解析为章节伪文件并整体替换(同一批次不与 md 混选)
if (epubFiles.length > 0) {
setEpubBusy(true);
try {
const chapters: PendingFile[] = [];
for (const f of epubFiles) {
if (f.size > MAX_EPUB_BYTES) {
msgs.push(`超过 50MB 已跳过:${f.name}`);
continue;
}
const parsed = await parseEpub(f);
for (const c of parsed) {
chapters.push({
name: `${String(chapters.length + 1).padStart(3, "0")}-${c.title || "未命名"}.md`,
text: c.markdown,
});
}
}
if (chapters.length > 0) {
if (chapters.length > MAX_FILES) {
msgs.push(`epub 解析出 ${chapters.length} 章,超过单次上限,已截取前 ${MAX_FILES} 章`);
}
setFiles(chapters.slice(0, MAX_FILES));
setSplitLevel("none");
setFromEpub(true);
} else if (!msgs.length) {
msgs.push("未能从 epub 中解析出章节");
}
} catch (e) {
msgs.push(e instanceof Error ? e.message : "epub 解析失败");
} finally {
setEpubBusy(false);
}
} else {
// md/txt:沿用原合并逻辑;当前若是 epub 章节列表则先清空
const picked: PendingFile[] = [];
const tooBig: string[] = [];
const failed: string[] = [];
for (const f of mdFiles) {
if (f.size > MAX_IMPORT_BYTES) {
tooBig.push(f.name);
continue;
}
try {
picked.push({ name: f.name, text: await f.text() });
} catch {
failed.push(f.name);
}
}
let truncated = false;
setFiles((prev) => {
const merged = [...(fromEpub ? [] : prev), ...picked];
// 文件名自然排序,保证 01、02…23 顺序
merged.sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true }));
truncated = merged.length > MAX_FILES;
return merged.slice(0, MAX_FILES);
});
if (fromEpub && picked.length > 0) {
setFromEpub(false);
msgs.push("已清空 epub 章节,改用所选 Markdown 文件");
}
if (tooBig.length) msgs.push(`超过 5MB 已跳过:${tooBig.join("、")}`);
if (failed.length) msgs.push(`无法读取:${failed.join("、")}`);
if (truncated) msgs.push(`单次最多 ${MAX_FILES} 个文件,已截取前 ${MAX_FILES} 个`);
}
if (msgs.length) setError(msgs.join(";"));
if (fileInputRef.current) fileInputRef.current.value = "";
}
function removeAt(idx: number) {
setFiles((prev) => prev.filter((_, i) => i !== idx));
}
async function submit() {
if (sourceFiles.length === 0) {
setError("请先选择文件或粘贴 Markdown 内容");
return;
}
if (oversizeFile) {
setError(`单个文件不能超过 5MB:${oversizeFile.name}`);
return;
}
if (overFiles) {
setError(`单次最多导入 ${MAX_FILES} 个文件`);
return;
}
if (overTotal) {
setError("内容总量过大:不能超过 20MB");
return;
}
if (oversizeChapters.length > 0) {
setError(
`以下章节正文超过 100000 字,请先拆分后再导入:${oversizeChapters.map((f) => f.name).join("、")}`
);
return;
}
setBusy(true);
setError("");
try {
const created = await onImport({
files: sourceFiles.map((f) => ({ filename: f.name, content: f.text })),
split_level: splitLevel,
});
toast(
sourceFiles.length > 1
? `已导入 ${sourceFiles.length} 个文件共 ${created.length} 章`
: `已导入 ${created.length} 章`,
"ok"
);
onClose(created);
} catch (e) {
setError(e instanceof Error ? e.message : "导入失败");
} finally {
setBusy(false);
}
}
const blocked = Boolean(oversizeFile || overFiles || overTotal || oversizeChapters.length > 0);
return (
<Modal
open
title="导入章节"
description="Markdown 按所选方式拆章;epub 按目录顺序导入,每个目录项一章"
onClose={onClose}
busy={busy}
maxWidthClass="max-w-xl"
bodyClassName="max-h-[60vh] overflow-y-auto j13-rail-scroll"
footer={
<>
<button type="button" className="btn btn-line" onClick={() => onClose()} disabled={busy}>
取消
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy || epubBusy || sourceFiles.length === 0 || blocked}
onClick={() => void submit()}
aria-busy={busy || epubBusy || undefined}
>
{busy || epubBusy ? (
<Loader2 size={14} className="animate-spin" aria-hidden />
) : (
<Upload size={14} aria-hidden />
)}
{busy || epubBusy ? (epubBusy ? "解析中…" : "导入中…") : "导入"}
</button>
</>
}
>
<div className="flex flex-col gap-4">
<div>
<p className="admin-doc-label">
来源
{sourceFiles.length > 1 ? `(${sourceFiles.length} 个文件,按文件名顺序导入)` : ""}
</p>
<div className="flex items-center gap-2">
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy || epubBusy}
onClick={() => fileInputRef.current?.click()}
>
<FileText size={13} aria-hidden /> 选择 .md / .txt / .epub 文件(可多选)
</button>
{files.length > 0 ? (
<button
type="button"
className="admin-doc-textbtn"
disabled={busy || epubBusy}
onClick={() => {
setFiles([]);
setFromEpub(false);
}}
>
清空
</button>
) : null}
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".md,.markdown,.txt,.epub,text/markdown,text/plain,application/epub+zip"
className="sr-only"
onChange={(e) => void pickFiles(e.target.files)}
/>
</div>
{files.length > 0 ? (
<ul
className="rounded-xl max-h-48 overflow-y-auto j13-rail-scroll"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
{files.map((f, i) => (
<li
key={`${f.name}-${i}`}
className="flex items-center gap-2 px-3 py-1.5"
style={{ borderBottom: i < files.length - 1 ? "1px solid var(--line)" : undefined }}
>
<span className="text-[12.5px] truncate min-w-0 flex-1" style={{ color: "var(--ink)" }}>
{f.name}
</span>
<span className="meta text-[11px] shrink-0 tabular-nums">{f.text.length} 字符</span>
<button
type="button"
className="w-5 h-5 shrink-0 rounded-md flex items-center justify-center hover:bg-[var(--panel)] transition-colors"
style={{ color: "var(--ink-3)" }}
aria-label={`移除 ${f.name}`}
disabled={busy}
onClick={() => removeAt(i)}
>
<X size={12} aria-hidden />
</button>
</li>
))}
</ul>
) : (
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="import-md-text">
或直接粘贴 Markdown
</label>
<textarea
id="import-md-text"
className="admin-field-input"
rows={6}
value={pasted}
onChange={(e) => setPasted(e.target.value)}
placeholder="# 第一章 …"
spellCheck={false}
/>
<p className="admin-doc-hint">{pasted.length} 字符</p>
</div>
)}
{fromEpub ? (
<p className="admin-doc-hint">epub 按目录顺序导入,每个目录项一章,不参与拆分。</p>
) : (
<div className="admin-doc-field">
<p className="admin-doc-label">拆分方式(对每个文件生效)</p>
<div className="flex flex-col gap-1.5">
{SPLIT_OPTIONS.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="import-split"
checked={splitLevel === opt.value}
onChange={() => setSplitLevel(opt.value)}
disabled={busy}
/>
<span className="text-[13px]" style={{ color: "var(--ink)" }}>
{opt.label}
</span>
<span className="meta text-[11.5px]">{opt.hint}</span>
</label>
))}
</div>
</div>
)}
<div className="admin-doc-field">
<p className="admin-doc-label">拆分预览</p>
{sourceFiles.length === 0 ? (
<p className="meta text-[12.5px]">等待内容…</p>
) : sourceFiles.length > 1 ? (
<>
<ul
className="rounded-xl px-3 py-2 max-h-40 overflow-y-auto j13-rail-scroll"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
{perFileCounts.map((f, i) => (
<li
key={`${f.name}-${i}`}
className="flex items-center justify-between gap-2 text-[12.5px] py-0.5"
style={{ color: "var(--ink-2)" }}
>
<span className="truncate">{f.name}</span>
<span className="meta text-[11.5px] shrink-0 tabular-nums">{f.count} 章</span>
</li>
))}
</ul>
<p className="admin-doc-hint">
共 {sourceFiles.length} 个文件 · 预计 {totalChapters} 章
</p>
</>
) : willFallback ? (
<p className="meta text-[12.5px]">
未检测到所选级别的标题,将整篇导入为一章(标题取文件名)。
</p>
) : (
<ul
className="rounded-xl px-3 py-2 max-h-40 overflow-y-auto j13-rail-scroll"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
{(splitLevel === "none" ? [single!.name] : singleTitles).map((t, i) => (
<li key={i} className="text-[12.5px] py-0.5 truncate" style={{ color: "var(--ink-2)" }}>
{i + 1}. {t}
</li>
))}
</ul>
)}
</div>
{overTotal ? <p className="meta text-[12.5px]">内容总量超过 20MB,无法导入。</p> : null}
{oversizeChapters.length > 0 ? (
<p className="meta text-[12.5px]">
以下章节正文超过 100000 字,无法导入:{oversizeChapters.map((f) => f.name).join("、")}
</p>
) : null}
{error ? (
<p className="admin-settings-row-error" role="alert">
{error}
</p>
) : null}
</div>
</Modal>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
"use client";
import { Images, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
/** 书籍封面图库选择弹窗:列出本人媒体库图片,选中后由父组件复制一份作封面素材 */
export default function LibraryCoverPicker({
open,
onClose,
onPick,
}: {
open: boolean;
onClose: () => void;
onPick: (item: MediaAttachment) => void;
}) {
const [items, setItems] = useState<MediaAttachment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
void apiMyMedia()
.then((res) => {
if (cancelled) return;
if (res.error) {
setError(res.error);
setItems([]);
return;
}
setItems((res.attachments || []).filter((item) => item.kind === "image"));
})
.catch(() => {
if (!cancelled) setError("加载媒体库失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open]);
return (
<Modal
open={open}
title="从图库选择封面"
description="选择后会复制一份作为封面素材,之后删除原图不影响封面。"
onClose={onClose}
maxWidthClass="max-w-lg"
dismissOnOverlay
>
{loading ? (
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
<Loader2 size={14} className="animate-spin" /> 加载中
</p>
) : error ? (
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
{error}
</p>
) : items.length === 0 ? (
<p className="meta text-[13px] py-6 text-center">
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip="复制一份用作封面"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="图库候选封面" className="w-full h-full object-contain" loading="lazy" />
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
>
<Images size={14} />
</span>
</button>
))}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,505 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
ChevronDown,
ChevronUp,
CornerDownRight,
ListTree,
Loader2,
Plus,
Save,
Trash2,
Upload,
} from "lucide-react";
import {
apiAdminCreateLibrarySection,
apiAdminDeleteLibrarySection,
apiAdminImportLibraryMarkdownBatch,
apiAdminMoveLibrarySection,
apiAdminUpdateLibrarySection,
type LibraryDoc,
type LibraryImportMdInput,
type LibrarySectionItem,
} from "@/lib/api";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminEmpty } from "@/components/admin";
import ImportMdDialog from "./ImportMdDialog";
const MAX_TITLE = 200;
const MAX_CONTENT = 100_000; // 与后端 MaxSectionContent 保持一致
const EMPTY_SNAPSHOT = JSON.stringify({ title: "", content: "" });
/**
* 「章节与正文」面板:左章节树(章 → 节 两级),右章节编辑器。
* 数据从 doc.sections 派生(后端 detail 已带全量含 content);
* 所有变更后调 onChanged() 让父组件刷新列表,doc.sections 随之更新。
*/
export default function LibrarySectionsPanel({
doc,
onChanged,
onDirtyChange,
}: {
doc: LibraryDoc;
/** 数据变更后由父组件刷新(doc.sections 随之更新) */
onChanged: () => Promise<void>;
/** dirty 状态上报,供父组件在关闭编辑器 / 切换条目前确认 */
onDirtyChange: (dirty: boolean) => void;
}) {
const sections = doc.sections ?? [];
// 平铺 → 章分组(顺序沿用后端 sort_order)
const groups = useMemo(() => {
const gs: { chapter: LibrarySectionItem; subs: LibrarySectionItem[] }[] = [];
for (const s of sections) {
if (s.parent_id == null) gs.push({ chapter: s, subs: [] });
else gs[gs.length - 1]?.subs.push(s);
}
return gs;
}, [sections]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [pendingNav, setPendingNav] = useState<(() => void) | null>(null);
const [deleteTarget, setDeleteTarget] = useState<LibrarySectionItem | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [importOpen, setImportOpen] = useState(false);
// 新建 / 导入后要选中的章节 id:等 doc.sections 里出现再应用,避免被旧数据清掉
const [pendingSelectId, setPendingSelectId] = useState<number | null>(null);
const selected = useMemo(
() => sections.find((s) => s.id === selectedId) ?? null,
[sections, selectedId]
);
const dirty = JSON.stringify({ title, content }) !== snapshot;
// dirty 上报给父组件;卸载时归零,避免残留的 dirty 影响父组件的关闭确认
useEffect(() => {
onDirtyChange(dirty);
return () => onDirtyChange(false);
}, [dirty, onDirtyChange]);
// 选中项被删除后清空选中
useEffect(() => {
if (selectedId != null && !sections.some((s) => s.id === selectedId)) {
setSelectedId(null);
}
}, [sections, selectedId]);
// 应用待选章节(新建 / 导入完成后)
useEffect(() => {
if (pendingSelectId == null) return;
const s = sections.find((x) => x.id === pendingSelectId);
if (!s) return;
setPendingSelectId(null);
setSelectedId(s.id);
setTitle(s.title);
setContent(s.content);
setSnapshot(JSON.stringify({ title: s.title, content: s.content }));
setError("");
}, [sections, pendingSelectId]);
function loadSection(s: LibrarySectionItem) {
setSelectedId(s.id);
setTitle(s.title);
setContent(s.content);
setSnapshot(JSON.stringify({ title: s.title, content: s.content }));
setError("");
}
/** dirty 时先弹确认,再执行动作(切换章节 / 打开导入 / 新增章节) */
function guardNav(action: () => void) {
if (dirty && !busy) setPendingNav(() => action);
else action();
}
function selectSection(s: LibrarySectionItem) {
if (s.id === selectedId) return;
guardNav(() => loadSection(s));
}
async function addSection(parent: LibrarySectionItem | null) {
setBusy(true);
setError("");
try {
const { section } = await apiAdminCreateLibrarySection(doc.id, {
parent_id: parent?.id,
title: parent ? "新节" : "新章",
});
await onChanged();
setPendingSelectId(section.id);
toast(parent ? "小节已添加" : "章已添加", "ok");
} catch (e) {
setError(e instanceof Error ? e.message : "添加章节失败");
} finally {
setBusy(false);
}
}
async function save() {
if (selectedId == null) return;
const t = title.trim();
if (!t) {
setError("章节标题不能为空");
return;
}
setBusy(true);
setError("");
try {
const { section } = await apiAdminUpdateLibrarySection(selectedId, {
title: t,
content,
});
await onChanged();
// 以服务端返回为准(后端会 trim 标题),保证 dirty 归零
setTitle(section.title);
setContent(section.content);
setSnapshot(JSON.stringify({ title: section.title, content: section.content }));
toast("章节已保存", "ok");
} catch (e) {
setError(e instanceof Error ? e.message : "保存章节失败");
} finally {
setBusy(false);
}
}
async function move(s: LibrarySectionItem, direction: "up" | "down") {
setBusy(true);
setError("");
try {
await apiAdminMoveLibrarySection(s.id, direction);
await onChanged(); // 选中 id 不变,原地刷新顺序
} catch (e) {
setError(e instanceof Error ? e.message : "移动章节失败");
} finally {
setBusy(false);
}
}
async function removeSection() {
if (!deleteTarget) return;
const target = deleteTarget;
setDeleteBusy(true);
try {
await apiAdminDeleteLibrarySection(target.id);
toast(target.parent_id == null ? "章已删除" : "小节已删除", "ok");
setDeleteTarget(null);
await onChanged();
if (target.id === selectedId) {
setSelectedId(null);
setTitle("");
setContent("");
setSnapshot(EMPTY_SNAPSHOT);
setError("");
}
} catch (e) {
toast(e instanceof Error ? e.message : "删除章节失败");
} finally {
setDeleteBusy(false);
}
}
async function handleImport(input: LibraryImportMdInput) {
const res = await apiAdminImportLibraryMarkdownBatch(doc.id, input);
await onChanged();
return res.sections;
}
function closeImport(created?: LibrarySectionItem[]) {
setImportOpen(false);
if (created && created.length > 0) setPendingSelectId(created[0].id);
}
/** 该章节在同级兄弟中的位置(供上移/下移禁用判断) */
function siblingPos(s: LibrarySectionItem): { first: boolean; last: boolean } {
if (s.parent_id == null) {
const i = groups.findIndex((g) => g.chapter.id === s.id);
return { first: i <= 0, last: i < 0 || i >= groups.length - 1 };
}
const g = groups.find((x) => x.subs.some((y) => y.id === s.id));
const subs = g?.subs ?? [];
const i = subs.findIndex((y) => y.id === s.id);
return { first: i <= 0, last: i < 0 || i >= subs.length - 1 };
}
function RowAction({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
className="w-6 h-6 shrink-0 rounded-md flex items-center justify-center hover:bg-[var(--panel)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
style={{ color: "var(--ink-3)" }}
title={label}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
function row(s: LibrarySectionItem, depth: 0 | 1, isFirst: boolean, isLast: boolean) {
const active = s.id === selectedId;
const pos = siblingPos(s);
return (
<div
key={s.id}
className="flex items-center gap-0.5 pr-1.5"
style={{
paddingLeft: depth === 0 ? 4 : 22,
background: active ? "var(--accent-soft)" : undefined,
boxShadow: active ? "inset 2px 0 0 var(--accent)" : undefined,
}}
>
<button
type="button"
onClick={() => selectSection(s)}
aria-current={active ? "true" : undefined}
className="min-w-0 flex-1 text-left px-2 py-2 flex items-center gap-1 rounded-md transition-colors hover:bg-[var(--panel-2)]"
title={s.title}
>
{depth === 1 ? (
<CornerDownRight
size={12}
className="shrink-0"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
) : null}
<span
className="truncate text-[13px]"
style={{
color: active ? "var(--accent)" : "var(--ink)",
fontWeight: depth === 0 ? 600 : 400,
}}
>
{s.title || "未命名章节"}
</span>
</button>
{active ? (
<>
<RowAction label="上移" disabled={pos.first || busy} onClick={() => void move(s, "up")}>
<ChevronUp size={13} aria-hidden />
</RowAction>
<RowAction label="下移" disabled={pos.last || busy} onClick={() => void move(s, "down")}>
<ChevronDown size={13} aria-hidden />
</RowAction>
{depth === 0 ? (
<RowAction
label="添加小节"
disabled={busy}
onClick={() => void addSection(s)}
>
<CornerDownRight size={13} aria-hidden />
</RowAction>
) : null}
<RowAction label="删除" onClick={() => setDeleteTarget(s)}>
<Trash2 size={13} aria-hidden />
</RowAction>
</>
) : null}
</div>
);
}
return (
<div className="flex h-full min-h-0">
{/* 左:章节树 */}
<aside
className="w-52 sm:w-60 shrink-0 flex flex-col min-h-0"
style={{ borderRight: "1px solid var(--line)" }}
aria-label="章节树"
>
<div
className="shrink-0 flex items-center gap-1.5 px-2.5 py-2.5 flex-wrap"
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => guardNav(() => void addSection(null))}
>
<Plus size={13} aria-hidden /> 添加章
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => guardNav(() => setImportOpen(true))}
>
<Upload size={13} aria-hidden /> 导入
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto j13-rail-scroll py-1.5">
{groups.length === 0 ? (
<p className="meta text-[12.5px] px-3 py-3 leading-relaxed">
还没有章节。点上方「添加章」手动编写,或「导入」批量导入 .md / .epub(epub 按目录顺序拆章)。
</p>
) : (
groups.map((g, gi) => (
<div key={g.chapter.id}>
{row(g.chapter, 0, gi === 0, gi === groups.length - 1)}
{g.subs.map((sub, si) => row(sub, 1, si === 0, si === g.subs.length - 1))}
</div>
))
)}
</div>
<div
className="shrink-0 px-3 py-2 meta text-[11.5px] tabular-nums"
style={{ borderTop: "1px solid var(--line)" }}
>
{sections.length} 章节
</div>
</aside>
{/* 右:章节编辑器 */}
<div className="flex-1 min-w-0 flex flex-col min-h-0">
{selected ? (
<>
<div className="flex-1 min-h-0 overflow-y-auto j13-rail-scroll px-4 sm:px-5 py-4 flex flex-col gap-4">
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="lib-section-title">
章节标题
</label>
<input
id="lib-section-title"
className="admin-field-input"
maxLength={MAX_TITLE}
value={title}
onChange={(e) => {
setError("");
setTitle(e.target.value);
}}
placeholder="例如:第一章 …"
disabled={busy}
/>
</div>
{/* flex-1 撑满滚动区剩余高度:编辑框最大化、提示行紧贴底部操作条上方 */}
<div className="admin-doc-md flex flex-col flex-1 min-h-0">
<label className="admin-doc-label" htmlFor="lib-section-content">
正文(Markdown)
</label>
<MarkdownEditor
id="lib-section-content"
value={content}
onChange={(v) => {
setError("");
setContent(v.slice(0, MAX_CONTENT));
}}
maxChars={MAX_CONTENT}
allowHide={false}
entriesAuto={doc.entries_auto ?? false}
placeholder="本章正文,支持 Markdown…"
rows={16}
ariaLabel="章节正文"
/>
</div>
</div>
{/* 底部操作条:不随正文滚动 */}
<footer
className="shrink-0 flex items-center gap-2 px-3.5 sm:px-4 py-3"
style={{ borderTop: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<div className="mr-auto flex items-center gap-3 min-w-0">
{error ? (
<p className="admin-settings-row-error" role="alert">
{error}
</p>
) : dirty ? (
<span className="meta text-[12px]">有未保存的修改</span>
) : null}
</div>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy || deleteBusy}
onClick={() => setDeleteTarget(selected)}
>
<Trash2 size={13} aria-hidden /> 删除章节
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy || !dirty}
title={dirty ? undefined : "没有需要保存的修改"}
onClick={() => void save()}
aria-busy={busy || undefined}
>
{busy ? (
<Loader2 size={13} className="animate-spin" aria-hidden />
) : (
<Save size={13} aria-hidden />
)}
{busy ? "保存中…" : "保存章节"}
</button>
</footer>
</>
) : (
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
<AdminEmpty
icon={<ListTree size={22} />}
title="未选择章节"
description="从左侧选择章节编辑,或添加章 / 导入 Markdown"
/>
</div>
)}
</div>
{/* 未保存切换章节确认 */}
<ConfirmDialog
open={!!pendingNav}
title="放弃未保存的修改?"
message="当前章节有未保存的修改,继续将丢失这些改动。"
danger
confirmLabel="放弃修改"
cancelLabel="继续编辑"
onCancel={() => setPendingNav(null)}
onConfirm={() => {
const action = pendingNav;
setPendingNav(null);
action?.();
}}
/>
{/* 删除章节确认 */}
<ConfirmDialog
open={!!deleteTarget}
title={deleteTarget?.parent_id == null ? "删除章" : "删除小节"}
message={
deleteTarget
? `确定删除「${deleteTarget.title}」吗?${
deleteTarget.parent_id == null ? "其下所有小节将一并删除," : ""
}且不可恢复。`
: ""
}
danger
busy={deleteBusy}
confirmLabel="删除"
onCancel={() => !deleteBusy && setDeleteTarget(null)}
onConfirm={() => void removeSection()}
/>
{/* 导入 Markdown 拆章 */}
{importOpen ? <ImportMdDialog onImport={handleImport} onClose={closeImport} /> : null}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { cookies } from "next/headers";
import LibraryAdmin from "./LibraryAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminLibraryDocs, type LibraryDoc } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
title: "书库管理",
};
export default async function AdminLibraryPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
return <Forbidden text="书库管理仅管理员及以上角色可操作。" />;
}
let initial: LibraryDoc[] = [];
let initError = "";
try {
const res = await fetchAdminLibraryDocs(cookie || undefined);
initial = res.docs ?? [];
} catch (e) {
initError = e instanceof Error ? e.message : "加载书库失败";
}
return (
<Suspense fallback={<p className="meta px-1 py-6">加载书库列表…</p>}>
<LibraryAdmin initial={initial} initError={initError} />
</Suspense>
);
}

View File

@@ -0,0 +1,91 @@
/**
* epub 导入解析(浏览器端):epub 本质是 zip + XHTML。
* 流程:JSZip 解压 → META-INF/container.xml 找 OPF → 按 spine 顺序读 XHTML
* → 清理(去 script/style、剔除内部相对路径图片)→ turndown 转 Markdown。
* 产物直接作为伪 .md 文件走现有批量导入接口,后端零改动。
* 两个依赖均在本模块内动态 import,避免打进主包。
*/
export interface EpubChapter {
title: string;
markdown: string;
}
/** 以 OPF 所在目录为基准解析 zip 内相对路径(处理 ./ ../ 与 URL 编码) */
function resolveZipPath(base: string, href: string): string {
const parts = `${base}${href}`.split("/");
const out: string[] = [];
for (const p of parts) {
if (!p || p === ".") continue;
if (p === "..") out.pop();
else out.push(p);
}
return out.join("/");
}
/** 清理标题里不适合做文件名的字符 */
function sanitizeTitle(t: string): string {
return t.replace(/[\\/:*?"<>|\n\r\t]+/g, " ").trim().slice(0, 60);
}
export async function parseEpub(file: File): Promise<EpubChapter[]> {
const { default: JSZip } = await import("jszip");
const { default: TurndownService } = await import("turndown");
const zip = await JSZip.loadAsync(await file.arrayBuffer()).catch(() => {
throw new Error("无法读取 epub:不是有效的 zip 文件");
});
// container.xml → OPF 路径
const containerEntry = zip.file("META-INF/container.xml");
if (!containerEntry) throw new Error("无法读取 epub:缺少 META-INF/container.xml");
const containerDoc = new DOMParser().parseFromString(await containerEntry.async("text"), "application/xml");
const rootfilePath = containerDoc.querySelector("rootfile")?.getAttribute("full-path");
if (!rootfilePath) throw new Error("无法读取 epub:container.xml 未声明 OPF 文件");
// OPF → manifest(id → href/mediaType)+ spine(阅读顺序)
const opfEntry = zip.file(rootfilePath) ?? zip.file(decodeURIComponent(rootfilePath));
if (!opfEntry) throw new Error(`无法读取 epub:缺少 OPF 文件 ${rootfilePath}`);
const opfDoc = new DOMParser().parseFromString(await opfEntry.async("text"), "application/xml");
const opfDir = rootfilePath.includes("/") ? rootfilePath.slice(0, rootfilePath.lastIndexOf("/") + 1) : "";
const manifest = new Map<string, { href: string; mediaType: string }>();
opfDoc.querySelectorAll("manifest > item").forEach((item) => {
const id = item.getAttribute("id");
const href = item.getAttribute("href");
if (id && href) manifest.set(id, { href, mediaType: item.getAttribute("media-type") ?? "" });
});
const spineIds = Array.from(opfDoc.querySelectorAll("spine > itemref"))
.map((el) => el.getAttribute("idref"))
.filter((id): id is string => !!id);
const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
const chapters: EpubChapter[] = [];
for (const id of spineIds) {
const item = manifest.get(id);
// spine 里也会有图片/样式等资源项,只处理 HTML 文档
if (!item || !/xhtml|html/i.test(item.mediaType)) continue;
const path = resolveZipPath(opfDir, item.href);
const entry = zip.file(path) ?? zip.file(decodeURIComponent(path));
if (!entry) continue;
const doc = new DOMParser().parseFromString(await entry.async("text"), "text/html");
doc.querySelectorAll("script, style").forEach((el) => el.remove());
// 内部相对路径图片导入后无法解析,剔除;外链 http(s) 图片保留
doc.querySelectorAll("img").forEach((img) => {
if (!/^https?:\/\//i.test(img.getAttribute("src") ?? "")) img.remove();
});
const body = doc.body;
if (!body) continue;
const markdown = td
.turndown(body.innerHTML)
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!markdown) continue; // 封面页等纯图片页
const heading = Array.from(body.querySelectorAll("h1, h2, h3, h4, h5, h6")).find((h) => h.textContent?.trim());
const title = sanitizeTitle(heading?.textContent ?? doc.title ?? "") || `第 ${chapters.length + 1} 章`;
chapters.push({ title, markdown });
}
if (chapters.length === 0) throw new Error("未能从 epub 中解析出任何章节");
return chapters;
}

View File

@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Ellipsis, ExternalLink, FileText, Plus } from "lucide-react";
import { apiAdminDeleteSitePage, type SitePage } from "@/lib/api";
import { apiAdminDeleteSitePage, apiAdminPurgeSitePage, type SitePage } from "@/lib/api";
import { formatDateTime, formatRelative } from "@/lib/format";
import { sitePagePublicPath } from "@/lib/sitePagePath";
import { useUrlStyle } from "@/components/SiteBrandProvider";
@@ -61,8 +61,14 @@ export default function PagesAdmin({
const [menuUp, setMenuUp] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const publishedCount = useMemo(() => list.filter((p) => p.published).length, [list]);
const draftCount = useMemo(() => list.filter((p) => !p.published).length, [list]);
const publishedCount = useMemo(
() => list.filter((p) => p.published && !p.deleted).length,
[list]
);
const draftCount = useMemo(
() => list.filter((p) => !p.published && !p.deleted).length,
[list]
);
const filtered = useMemo(() => {
if (statusFilter === "published") return list.filter((p) => p.published);
@@ -93,9 +99,16 @@ export default function PagesAdmin({
async function remove(p: SitePage) {
setDeletingId(p.id);
try {
await apiAdminDeleteSitePage(p.id);
setList((l) => l.filter((x) => x.id !== p.id));
toast("页面已删除", "ok");
if (p.deleted) {
// 已软删的页面:彻底删除(不可恢复,释放 slug)
await apiAdminPurgeSitePage(p.id);
setList((l) => l.filter((x) => x.id !== p.id));
toast("页面已彻底删除,地址可重新使用", "ok");
} else {
await apiAdminDeleteSitePage(p.id);
setList((l) => l.map((x) => (x.id === p.id ? { ...x, deleted: true } : x)));
toast("页面已删除,可在列表中彻底删除以释放地址", "ok");
}
} catch (e) {
toast(e instanceof Error ? e.message : "删除失败");
} finally {
@@ -170,20 +183,30 @@ export default function PagesAdmin({
const updated = p.updated_at || "";
const path = sitePagePublicPath(p.slug, urlStyle);
return (
<tr key={p.id}>
<tr key={p.id} style={p.deleted ? { opacity: 0.65 } : undefined}>
<td data-label="页面">
<Link href={editHref(p.id, statusFilter)} className="admin-doc-title" title={p.title}>
{p.title}
</Link>
{p.deleted ? (
<span className="admin-doc-title" title={p.title}>
{p.title}
</span>
) : (
<Link href={editHref(p.id, statusFilter)} className="admin-doc-title" title={p.title}>
{p.title}
</Link>
)}
<p className="admin-doc-path">
<code>{path}</code>
</p>
{p.excerpt ? <p className="admin-doc-excerpt">{p.excerpt}</p> : null}
</td>
<td data-label="状态">
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
{p.published ? "已发布" : "草稿"}
</AdminStatusChip>
{p.deleted ? (
<AdminStatusChip tone="danger">已删除</AdminStatusChip>
) : (
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
{p.published ? "已发布" : "草稿"}
</AdminStatusChip>
)}
</td>
<td data-label="展示位置">{placeLabel(p)}</td>
<td data-label="排序" className="tabular-nums">
@@ -200,10 +223,12 @@ export default function PagesAdmin({
</td>
<td data-label="操作">
<div className="admin-doc-row-actions">
<Link href={editHref(p.id, statusFilter)} className="admin-doc-textbtn">
编辑
</Link>
{p.published ? (
{!p.deleted ? (
<Link href={editHref(p.id, statusFilter)} className="admin-doc-textbtn">
编辑
</Link>
) : null}
{p.published && !p.deleted ? (
<a
href={path}
target="_blank"
@@ -248,7 +273,7 @@ export default function PagesAdmin({
setDeleteTarget(p);
}}
>
删除
{p.deleted ? "彻底删除" : "删除"}
</button>
</div>
) : null}
@@ -277,15 +302,17 @@ export default function PagesAdmin({
<ConfirmDialog
open={!!deleteTarget}
title="删除单页"
title={deleteTarget?.deleted ? "彻底删除单页" : "删除单页"}
message={
deleteTarget
? `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,后台也不能恢复。`
? deleteTarget.deleted
? `确定彻底删除「${deleteTarget.title}」吗?删除后不可恢复,地址将释放可重新使用。`
: `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,可在列表中彻底删除以释放地址。`
: ""
}
danger
busy={deletingId != null}
confirmLabel="删除"
confirmLabel={deleteTarget?.deleted ? "彻底删除" : "删除"}
onCancel={() => deletingId == null && setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) void remove(deleteTarget);

View File

@@ -6,6 +6,8 @@ import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import BasicIdentityPanel from "./BasicIdentityPanel";
import BrandSeoPanel from "./BrandSeoPanel";
import FooterLinksPanel from "./FooterLinksPanel";
import HeaderNavPanel from "./HeaderNavPanel";
import MobileTabBarPanel from "./MobileTabBarPanel";
import UrlStylePanel from "./UrlStylePanel";
import type { PublicSettings } from "@/lib/api";
@@ -15,19 +17,25 @@ export default function BasicSettings({ initial }: { initial: PublicSettings })
const [brandDirty, setBrandDirty] = useState(false);
const [seoDirty, setSeoDirty] = useState(false);
const [urlStyleDirty, setUrlStyleDirty] = useState(false);
const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty;
const [mobileTabsDirty, setMobileTabsDirty] = useState(false);
const [headerNavDirty, setHeaderNavDirty] = useState(false);
const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty || mobileTabsDirty || headerNavDirty;
const leave = useUnsavedGuard(dirty);
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
const onUrlStyleDirty = useCallback((d: boolean) => setUrlStyleDirty(d), []);
const onMobileTabsDirty = useCallback((d: boolean) => setMobileTabsDirty(d), []);
const onHeaderNavDirty = useCallback((d: boolean) => setHeaderNavDirty(d), []);
return (
<div className="admin-settings-stack">
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
<UrlStylePanel initial={live} onSaved={setLive} onDirtyChange={onUrlStyleDirty} />
<HeaderNavPanel initial={live} onSaved={setLive} onDirtyChange={onHeaderNavDirty} />
<MobileTabBarPanel initial={live} onSaved={setLive} onDirtyChange={onMobileTabsDirty} />
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
<ConfirmDialog
open={leave.leaveOpen}

View File

@@ -1,6 +1,6 @@
"use client";
import { ImagePlus, Images, X } from "lucide-react";
import { Check, ImagePlus, Images, Link2, X } from "lucide-react";
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
AdminSegmented,
@@ -13,6 +13,7 @@ import {
BRAND_LOGO_FITS,
BRAND_LOGO_SIZES,
brandAccept,
brandAliasPath,
brandFileOk,
type BrandLogoFit,
type BrandLogoSize,
@@ -75,6 +76,7 @@ export default function BrandSeoPanel({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [picker, setPicker] = useState<BrandSlot | null>(null);
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
const blobs = useRef<string[]>([]);
const saveGen = useRef(0);
@@ -203,6 +205,21 @@ export default function BrandSeoPanel({
const slotDraft = (id: BrandSlot) =>
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
// 已保存的槽位 URL:存在才展示固定地址(未保存过无图可引用)
const savedOf = (slot: BrandSlot) =>
slot === "logo_dark" ? saved.dark : slot === "favicon" ? saved.favicon : saved.light;
const copyAlias = async (slot: BrandSlot) => {
try {
await navigator.clipboard.writeText(`${window.location.origin}${brandAliasPath(slot)}`);
setCopiedSlot(slot);
toast("已复制固定地址", "ok");
window.setTimeout(() => setCopiedSlot((cur) => (cur === slot ? null : cur)), 1500);
} catch {
toast("复制失败,请手动复制");
}
};
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
const draft = slotDraft(slot);
const preview = previewOf(draft);
@@ -224,6 +241,18 @@ export default function BrandSeoPanel({
</div>
<div className="admin-brand-asset-meta">
<span className="admin-brand-asset-label">{label}</span>
{savedOf(slot) ? (
<button
type="button"
className="admin-brand-alias"
title="点击复制固定地址(换图不变链,友链引用请用此地址)"
onClick={() => void copyAlias(slot)}
>
<Link2 size={12} aria-hidden />
<span className="admin-brand-alias-url">{brandAliasPath(slot)}</span>
{copiedSlot === slot ? <Check size={12} aria-hidden /> : null}
</button>
) : null}
<div className="admin-brand-asset-actions">
<label className="btn btn-line btn-sm cursor-pointer">
<ImagePlus size={14} /> 上传

View File

@@ -0,0 +1,322 @@
"use client";
import { ArrowDown, ArrowUp, BookOpen, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
import { useEffect, useState } from "react";
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import {
HEADER_NAV_BUILTIN_KEYS,
HEADER_NAV_BUILTIN_LABELS,
HEADER_NAV_LIMITS,
isHeaderNavExternal,
type HeaderNavBuiltinKey,
type HeaderNavItem,
} from "@/lib/headerNav";
import { footerURLAllowed } from "@/lib/brand";
import { toast } from "@/lib/toast";
const BUILTIN_ICONS: Record<HeaderNavBuiltinKey, React.ReactNode> = {
home: <Home size={14} />,
leaderboard: <Trophy size={14} />,
library: <BookOpen size={14} />,
};
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
const BUILTIN_HINTS: Record<HeaderNavBuiltinKey, string> = {
home: "站点首页 /",
leaderboard: "排行榜页(地址随伪静态风格变化)",
library: "书库页(地址随伪静态风格变化)",
};
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
function clone(items: HeaderNavItem[]): HeaderNavItem[] {
return items.map((item) => ({ ...item }));
}
function switchesOf(items: HeaderNavItem[]): BuiltinSwitches {
const present = new Set(
items.filter((it): it is Extract<HeaderNavItem, { type: "builtin" }> => it.type === "builtin").map((it) => it.key),
);
return {
home: present.has("home"),
leaderboard: present.has("leaderboard"),
library: present.has("library"),
};
}
/** 完整行列表:已启用项按配置顺序在前,未启用的内置项按默认顺序追加在后 */
function rowsOf(items: HeaderNavItem[]): HeaderNavItem[] {
const present = new Set(
items.filter((it): it is Extract<HeaderNavItem, { type: "builtin" }> => it.type === "builtin").map((it) => it.key),
);
const hidden = HEADER_NAV_BUILTIN_KEYS.filter((key) => !present.has(key)).map((key) => ({
type: "builtin" as const,
key,
}));
return [...clone(items), ...hidden];
}
/**
* 桌面端 Header 品牌名右侧的主导航:排序、开关显隐内置项、增删自定义链接,整体替换保存。
* 与移动端底栏面板同款交互:操作按钮居右,内置项开关隐藏(隐藏项保留排序位置)。
*/
export default function HeaderNavPanel({
initial,
onSaved,
onDirtyChange,
}: {
initial: PublicSettings;
onSaved?: (next: PublicSettings) => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [rows, setRows] = useState<HeaderNavItem[]>(() => rowsOf(initial.header_nav));
const [on, setOn] = useState<BuiltinSwitches>(() => switchesOf(initial.header_nav));
const [savedPayload, setSavedPayload] = useState<HeaderNavItem[]>(() => clone(initial.header_nav));
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
// 新增自定义行挂载后自动聚焦名称输入框
const [focusIdx, setFocusIdx] = useState<number | null>(null);
// 提交负载 = 自定义链接 + 开关打开的内置项,按行顺序
const payload: HeaderNavItem[] = rows.filter((item) => item.type === "custom" || on[item.key]);
const dirty = JSON.stringify(payload) !== JSON.stringify(savedPayload);
const atCap = payload.length >= HEADER_NAV_LIMITS.items;
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
useEffect(() => {
if (focusIdx !== null) setFocusIdx(null);
}, [focusIdx]);
const move = (i: number, dir: -1 | 1) => {
setError("");
setRows((prev) => {
const j = i + dir;
if (j < 0 || j >= prev.length) return prev;
const next = [...prev];
const [row] = next.splice(i, 1);
next.splice(j, 0, row);
return next;
});
};
const removeAt = (i: number) => {
setError("");
setRows((prev) => prev.filter((_, idx) => idx !== i));
};
const addCustom = () => {
if (atCap) return;
setError("");
setFocusIdx(rows.length);
setRows((prev) => [...prev, { type: "custom", label: "", url: "", new_tab: false }]);
};
const updateCustom = (i: number, patch: Partial<Extract<HeaderNavItem, { type: "custom" }>>) => {
setError("");
setRows((prev) => prev.map((item, idx) => (idx === i && item.type === "custom" ? { ...item, ...patch } : item)));
};
const discard = () => {
setRows(rowsOf(savedPayload));
setOn(switchesOf(savedPayload));
setError("");
};
const save = async () => {
if (saving || !dirty) return;
for (const item of payload) {
if (item.type !== "custom") continue;
if (!item.label.trim()) {
setError("自定义菜单名称不能为空");
toast("自定义菜单名称不能为空");
return;
}
if (!footerURLAllowed(item.url)) {
setError(`地址无效:${item.url || "(空)"}`);
toast(`地址无效:${item.url || "(空)"}`);
return;
}
}
const body: HeaderNavItem[] = payload.map((item) =>
item.type === "custom"
? { type: "custom", label: item.label.trim(), url: item.url.trim(), new_tab: item.new_tab }
: { type: "builtin", key: item.key },
);
setSaving(true);
try {
const res = await apiUpdateSiteSettings({ header_nav: body });
setSavedPayload(clone(res.header_nav));
setRows(rowsOf(res.header_nav));
setOn(switchesOf(res.header_nav));
onSaved?.(res);
toast("已保存,顶部导航已更新", "ok");
} catch (e) {
const msg = e instanceof Error ? e.message : "保存失败";
setError(msg);
toast(msg);
} finally {
setSaving(false);
}
};
return (
<AdminSettingsGroup
id="settings-header-nav"
title="顶部导航菜单"
description="桌面端页眉品牌名右侧的菜单,顺序即前台展示顺序;移动端导航由底部菜单承担。全部隐藏后前台不显示导航。"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存"
error={error}
onSave={() => void save()}
onDiscard={discard}
/>
}
>
<div className="flex flex-col gap-2">
{rows.map((item, i) => {
const enabled = item.type === "custom" || on[item.key];
return (
<div
key={item.type === "builtin" ? `builtin-${item.key}` : `custom-${i}`}
className="flex flex-wrap items-center gap-2 rounded-xl px-2 py-2"
style={{
border: "1px solid var(--line)",
background: "var(--panel)",
opacity: enabled ? 1 : 0.55,
}}
>
{item.type === "builtin" ? (
<>
<span
className="w-7 h-7 flex items-center justify-center rounded-full shrink-0"
style={{ color: "var(--ink-2)", background: "var(--panel-2)" }}
aria-hidden
>
{BUILTIN_ICONS[item.key]}
</span>
<span className="text-[13.5px] font-semibold shrink-0" style={{ color: "var(--ink)" }}>
{HEADER_NAV_BUILTIN_LABELS[item.key]}
</span>
<span className="meta text-[12px] truncate">{BUILTIN_HINTS[item.key]}</span>
</>
) : (
<>
<div className="admin-field-shell w-32 shrink-0">
<input
className="admin-field-input"
style={{ padding: "7px 10px" }}
maxLength={HEADER_NAV_LIMITS.label}
value={item.label}
onChange={(e) => updateCustom(i, { label: e.target.value })}
placeholder="菜单名称"
aria-label={`第 ${i + 1} 个自定义菜单名称`}
autoFocus={focusIdx === i}
/>
</div>
<div className="admin-field-shell min-w-[10rem] flex-1">
<input
className="admin-field-input"
style={{ padding: "7px 10px" }}
maxLength={HEADER_NAV_LIMITS.url}
value={item.url}
onChange={(e) => {
const v = e.target.value;
const wasExt = isHeaderNavExternal(item.url);
const nowExt = isHeaderNavExternal(v);
updateCustom(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
}}
placeholder="https://… 或 /p/about"
aria-label={`第 ${i + 1} 个自定义菜单地址`}
/>
</div>
<div className="shrink-0 w-[8.75rem]">
<div className="admin-footer-open-toggle" role="group" aria-label={`第 ${i + 1} 个菜单打开方式`}>
<button
type="button"
className={`admin-footer-open-opt${!item.new_tab ? " is-on" : ""}`}
aria-pressed={!item.new_tab}
onClick={() => updateCustom(i, { new_tab: false })}
>
本页
</button>
<button
type="button"
className={`admin-footer-open-opt${item.new_tab ? " is-on" : ""}`}
aria-pressed={item.new_tab}
onClick={() => updateCustom(i, { new_tab: true })}
>
<ExternalLink size={12} aria-hidden />
新标签
</button>
</div>
</div>
</>
)}
<div className="flex items-center gap-1 shrink-0 ml-auto">
<button
type="button"
className="admin-icon-btn"
aria-label="上移"
disabled={i === 0 || saving}
onClick={() => move(i, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="admin-icon-btn"
aria-label="下移"
disabled={i === rows.length - 1 || saving}
onClick={() => move(i, 1)}
>
<ArrowDown size={14} />
</button>
{item.type === "builtin" ? (
<AdminSwitch
checked={on[item.key]}
disabled={saving || (!on[item.key] && atCap)}
onChange={() => {
setError("");
setOn((prev) => ({ ...prev, [item.key]: !prev[item.key] }));
}}
label={`顶部导航显示${HEADER_NAV_BUILTIN_LABELS[item.key]}`}
/>
) : (
<button
type="button"
className="admin-icon-btn is-danger"
aria-label="移除"
onClick={() => removeAt(i)}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
);
})}
<div className="flex flex-wrap items-center gap-2 pt-1">
<button type="button" className="btn btn-line btn-sm" disabled={atCap} onClick={addCustom}>
<Plus size={14} /> 新增自定义链接
<span className="meta text-[12px] ml-1">
{payload.length}/{HEADER_NAV_LIMITS.items}
</span>
</button>
</div>
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
<li>内置菜单(首页/排行榜/书库)用开关控制显隐,地址由系统按伪静态风格自动生成;隐藏项保留排序位置。</li>
<li>自定义链接支持站内路径(如 /p/about、/announcements)或 http(s) 外链;名称最多 16 字。</li>
</ul>
</div>
</AdminSettingsGroup>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useEffect, useState } from "react";
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
import { toast } from "@/lib/toast";
/** 底栏入口说明 */
const TAB_META: Record<MobileTabKey, { label: string; hint: string }> = {
home: { label: "首页", hint: "站点首页" },
library: { label: "书库", hint: "书库列表页入口" },
compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" },
messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" },
me: { label: "我的", hint: "个人主页;未登录指向登录页" },
};
type TabSwitches = Record<MobileTabKey, boolean>;
function switchesOf(tabs: MobileTabKey[]): TabSwitches {
return {
home: tabs.includes("home"),
library: tabs.includes("library"),
compose: tabs.includes("compose"),
messages: tabs.includes("messages"),
me: tabs.includes("me"),
};
}
/** 完整顺序:已启用项按配置顺序在前,未启用项按默认顺序追加在后 */
function orderOf(tabs: MobileTabKey[]): MobileTabKey[] {
const enabled = normalizeMobileTabs(tabs);
const rest = MOBILE_TABS.filter((key) => !enabled.includes(key));
return [...enabled, ...rest];
}
/** 移动端底栏:开关 + 顺序调整,保存启用的 tab 子集(数组顺序即前台展示顺序) */
export default function MobileTabBarPanel({
initial,
onSaved,
onDirtyChange,
}: {
initial: PublicSettings;
onSaved?: (next: PublicSettings) => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [order, setOrder] = useState<MobileTabKey[]>(() => orderOf(initial.mobile_tabs));
const [on, setOn] = useState<TabSwitches>(() => switchesOf(normalizeMobileTabs(initial.mobile_tabs)));
const [savedPayload, setSavedPayload] = useState<MobileTabKey[]>(() => normalizeMobileTabs(initial.mobile_tabs));
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const payload = order.filter((key) => on[key]);
const dirty = JSON.stringify(payload) !== JSON.stringify(savedPayload);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const move = (i: number, dir: -1 | 1) => {
setError("");
setOrder((prev) => {
const j = i + dir;
if (j < 0 || j >= prev.length) return prev;
const next = [...prev];
const [row] = next.splice(i, 1);
next.splice(j, 0, row);
return next;
});
};
const discard = () => {
setOrder(orderOf(savedPayload));
setOn(switchesOf(savedPayload));
setError("");
};
const save = async () => {
if (saving || !dirty) return;
if (payload.length === 0) {
setError("至少保留一个入口");
toast("至少保留一个入口");
return;
}
setSaving(true);
setError("");
try {
const res = await apiUpdateSiteSettings({ mobile_tabs: [...payload] });
const nextSaved = normalizeMobileTabs(res.mobile_tabs);
setSavedPayload(nextSaved);
setOrder(orderOf(nextSaved));
setOn(switchesOf(nextSaved));
onSaved?.(res);
toast("已保存,移动端底栏已更新", "ok");
} catch (e) {
const msg = e instanceof Error ? e.message : "保存失败";
setError(msg);
toast(msg);
} finally {
setSaving(false);
}
};
return (
<AdminSettingsGroup
id="settings-mobile-tabbar"
title="移动端底栏"
description="控制移动端底部导航显示哪些入口及顺序;底栏只在各入口自己的首页显示。"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存"
error={error}
saveDisabled={payload.length === 0}
onSave={() => void save()}
onDiscard={discard}
/>
}
>
<div className="flex flex-col gap-2">
{order.map((key, i) => (
<div
key={key}
className="flex items-center gap-2 rounded-xl px-2 py-2"
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
>
<div className="min-w-0 flex-1">
<div className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
{TAB_META[key].label}
</div>
<div className="meta text-[12px]">{TAB_META[key].hint}</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
className="admin-icon-btn"
aria-label={`上移${TAB_META[key].label}`}
disabled={i === 0 || saving}
onClick={() => move(i, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="admin-icon-btn"
aria-label={`下移${TAB_META[key].label}`}
disabled={i === order.length - 1 || saving}
onClick={() => move(i, 1)}
>
<ArrowDown size={14} />
</button>
<AdminSwitch
checked={on[key]}
disabled={saving}
onChange={() => {
setError("");
setOn((prev) => ({ ...prev, [key]: !prev[key] }));
}}
label={`底栏显示${TAB_META[key].label}`}
/>
</div>
</div>
))}
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
<li>顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。</li>
<li>至少保留一个入口;全部关闭将无法保存。</li>
<li>排行榜不在底栏展示,移动端入口由首页抽屉承担。</li>
</ul>
</div>
</AdminSettingsGroup>
);
}

View File

@@ -3,7 +3,16 @@
import { useEffect, useState } from "react";
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { announcementPath, leaderboardPath, pagePath, postPath, userPath, type UrlStyle } from "@/lib/urlStyle";
import {
announcementPath,
leaderboardPath,
libraryDocPath,
libraryPath,
pagePath,
postPath,
userPath,
type UrlStyle,
} from "@/lib/urlStyle";
import { toast } from "@/lib/toast";
/** 三档风格展示信息:示例 id/slug 固定,方便站长对比形态 */
@@ -20,6 +29,8 @@ function samplePaths(style: UrlStyle) {
{ kind: "公告", path: announcementPath(style, 1) },
{ kind: "自建页", path: pagePath(style, "about") },
{ kind: "排行榜", path: leaderboardPath(style) },
{ kind: "书库", path: libraryPath(style) },
{ kind: "书籍", path: libraryDocPath(style, "slug") },
];
}
@@ -117,6 +128,7 @@ export default function UrlStylePanel({
<li>保存后全站前台链接立即按新风格生成;旧风格链接自动 301 到新风格,收录与外链不断链。</li>
<li>301 会被浏览器长期缓存,切换前请想清楚;建议长期固定一种风格,不要频繁来回切换。</li>
<li>自建页与排行榜同样参与伪静态;自建页 slug 含中文时自动 URL 编码,地址栏多数浏览器仍显示中文。</li>
<li>书库列表与书籍详情参与伪静态(两种伪静态风格同模板);阅读页保持标准路径不参与。</li>
</ul>
</AdminSettingsGroup>
);

View File

@@ -105,17 +105,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
<>
<JsonLd data={jsonLd} />
<SiteDocShell
left={left}
right={right}
mobileExtras={
<>
{toc.length > 0 ? <PostToc items={toc} /> : null}
<SiteDocAnnoNav items={announcements} currentId={annId} />
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
</>
}
>
<SiteDocShell left={left} right={right}>
<article className="panel p-6 sm:p-8">
<SiteDocMasthead
kicker="站点公告"
@@ -144,7 +134,8 @@ export default async function AnnouncementPage({ params }: PageProps) {
/>
</article>
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
{/* 手机端 #main 无底部内边距,补 mb 与 footer 拉开距离;桌面端由 lg:pb 提供,归零 */}
<div className="mt-5 mb-6 lg:mb-0 flex items-center gap-2 text-[12.5px] meta">
<Megaphone size={14} style={{ color: "var(--accent)" }} />
<span>由站点管理团队发布</span>
</div>

View File

@@ -145,13 +145,7 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
);
return (
<SiteDocShell
left={left}
right={right}
mobileExtras={
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
}
>
<SiteDocShell left={left} right={right}>
<div className="panel overflow-hidden">
<header
className="px-5 sm:px-6 pt-5 pb-4"

View File

@@ -215,6 +215,9 @@
}
html {
/* 恒定保留滚动条槽位:短页面(如书库)无滚动条时视口宽度不再比长页面宽 10px,
避免 Header 居中容器与右侧按钮组在切页时横移 */
scrollbar-gutter: stable;
scrollbar-color: var(--line-2) transparent;
background-color: var(--bg); /* 橡皮筋 overscroll 与画布同色 */
--j13-bg-site-image: none;
@@ -356,6 +359,23 @@ html[data-lv-fx="off"] .lv-text {
-webkit-user-select: none;
}
/* 无封面文本封面:颜色由书名派生的稳定色相 --cover-h 决定(lib/coverColor.ts),
同一本书任何页面/刷新同色;浅色=淡彩底深字,深色=暗底亮字 */
.j13-text-cover {
--cover-h: 210;
color: hsl(var(--cover-h) 45% 34%);
background:
radial-gradient(120% 140% at 20% -10%, hsl(var(--cover-h) 85% 55% / 0.2), transparent 60%),
hsl(var(--cover-h) 60% 95.5%);
}
.dark .j13-text-cover {
color: hsl(var(--cover-h) 65% 78%);
background:
radial-gradient(120% 140% at 20% -10%, hsl(var(--cover-h) 70% 62% / 0.22), transparent 60%),
hsl(var(--cover-h) 28% 16%);
}
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
.j13-tip-bubble {
position: fixed;
@@ -1967,6 +1987,10 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
border-radius: 10px;
color: var(--ink-2);
transition: background 0.12s ease, color 0.12s ease;
/* 作为 flex 子元素时保持固定宽度,避免被压缩或按内容计算,
否则窄屏下直接挂在 .j13-md-tool-groups 内的按钮会塌缩成图标宽度(如 15px),
而被 .j13-md-popover-anchor 包裹的按钮保持 34px,导致整行按钮宽度不一致 */
flex: 0 0 auto;
}
.j13-md-tool:hover:not(:disabled) {
background: var(--accent-soft);
@@ -2397,6 +2421,38 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
height: 32px;
padding: 0 8px;
}
/* 窄屏(手机端)编辑工具栏:收成单行核心工具 + 「更多」菜单(评论栏与正文编辑器共用) */
.j13-md-editor.is-narrow .j13-md-toolbar {
flex-wrap: nowrap;
gap: 2px;
padding: 2px 0 8px;
}
.j13-md-editor.is-narrow .j13-md-tool-groups {
flex-wrap: nowrap;
}
.j13-md-editor.is-narrow .j13-md-tool {
width: 30px;
height: 30px;
}
.j13-md-editor.is-narrow .j13-md-sep {
/* 窄屏下 1px 分隔符视觉几乎不可见却占 5px 横向空间,
反而打断按钮间均匀间距;改由 gap-0.5 统一控制间距 */
display: none;
}
.j13-md-editor.is-narrow .j13-md-mode button {
height: 28px;
padding: 0 7px;
font-size: 12px;
}
/* 「更多」内嵌的代码块/表情/隐藏内容弹层:相对更多按钮居中,避免贴边溢出屏幕 */
.j13-md-popover-center {
left: 50%;
transform: translateX(-50%);
}
/* 窄屏左锚点表单弹层(链接等)宽度收敛,保证完整落在屏内 */
.j13-md-editor.is-narrow .j13-md-popover-form {
min-width: 232px;
}
/* 正文内嵌隐藏块 */
.j13-hide-block {
@@ -2857,9 +2913,10 @@ body:has([data-compose-viewport]) > footer {
min-width: 0;
}
/* <1024px(移动/平板,底部公共菜单显示区间):三区堆叠、统一左对齐连成整体,
避免「左中右各自断开」的残缺观感;底部避让公共菜单 */
避免「左中右各自断开」的残缺观感;底栏避让统一由 footer 承担(:has 判定),
#main 只留常规小 padding-bottom,无底栏页面(如全部通知)底部也有间隙 */
@media (max-width: 1023px) {
.site-footer {
body:has([data-j13-tabbar]) .site-footer {
padding-bottom: calc(4.75rem + env(safe-area-inset-bottom));
}
.site-footer-inner {
@@ -2881,6 +2938,15 @@ body:has([data-compose-viewport]) > footer {
}
}
/* 通知下拉(移动端底部面板)避让底部公共菜单:面板在 sticky header 的层叠上下文内,
z-index 抬不过底栏,只能在底栏存在时整体抬高(与 footer 的避让值一致),
露出被遮住的「查看全部通知」入口 */
@media (max-width: 1023.98px) {
body:has([data-j13-tabbar]) .j13-notif-sheet {
bottom: calc(4.75rem + env(safe-area-inset-bottom));
}
}
body:has([data-compose-viewport]) #main {
flex: 1 1 0;
min-height: 0;
@@ -3103,7 +3169,8 @@ body:has([data-compose-viewport]) #main > [data-compose-viewport] {
.j13-compose-typemodal-body {
max-height: min(78vh, 42rem);
overflow-y: auto;
padding-right: 2px;
/* 滚动容器会裁剪 box-shadow 聚焦圈(2px),四周各留 2px */
padding: 2px;
}
.j13-compose-typemodal-body .j13-compose-typepanel {
padding: 0;
@@ -3197,6 +3264,13 @@ body:has([data-chat-viewport]) #main {
overflow: hidden;
}
/* 消息列表页显示底栏时,锁视口布局底部避让(/chat/[id] 无底栏不受影响) */
@media (max-width: 1023.98px) {
body:has([data-j13-tabbar]) #main:has([data-chat-viewport]) {
padding-bottom: calc(4.75rem + env(safe-area-inset-bottom));
}
}
/* ── 管理后台独立壳:全高侧栏 + 顶栏 + 可滚主区(不依赖公开 Header/Footer) ── */
body:has([data-admin-viewport]) {
height: 100dvh;
@@ -3809,6 +3883,19 @@ a.j13-mention-token:hover {
border-radius: var(--r);
box-shadow: var(--shadow-sm);
}
/* 阅读型页面(公文壳 / 书库读页)手机端中栏去块化:
直接子级 panel 贴边铺满(-mx-4 抵消 #main 移动端 px-4),与帖子详情页移动端同款;
仅作用于直接子级,正文内的条目卡等嵌套 panel 不受影响 */
@media (max-width: 639.98px) {
.j13-doc-bleed > .panel {
margin-left: -1rem;
margin-right: -1rem;
border-radius: 0;
border-left-width: 0;
border-right-width: 0;
box-shadow: none;
}
}
.hover-lift {
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
@@ -4444,6 +4531,30 @@ a.j13-mention-token:hover {
align-items: center;
gap: 0.3rem;
}
/* 品牌图固定对外地址:点击复制,友链引用用 */
.admin-brand-alias {
display: flex;
align-items: center;
gap: 0.3rem;
min-width: 0;
padding: 0.2rem 0.45rem;
border-radius: 8px;
box-shadow: inset 0 0 0 1px var(--line);
font-size: 11.5px;
color: var(--ink-2);
text-align: left;
}
.admin-brand-alias:hover {
color: var(--ink);
box-shadow: inset 0 0 0 1px var(--accent, var(--line));
}
.admin-brand-alias-url {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-jetbrains), ui-monospace, monospace;
}
.admin-settings-row {
display: grid;
grid-template-columns: 10.75rem minmax(0, 1fr);
@@ -4601,13 +4712,17 @@ a.j13-mention-token:hover {
#settings-identity .admin-settings-actions,
#settings-brand .admin-settings-actions,
#settings-url-style .admin-settings-actions,
#settings-footer .admin-settings-actions {
#settings-footer .admin-settings-actions,
#settings-header-nav .admin-settings-actions,
#settings-mobile-tabbar .admin-settings-actions {
grid-template-columns: minmax(0, 1fr);
}
#settings-identity .admin-settings-actions-inner,
#settings-brand .admin-settings-actions-inner,
#settings-url-style .admin-settings-actions-inner,
#settings-footer .admin-settings-actions-inner {
#settings-footer .admin-settings-actions-inner,
#settings-header-nav .admin-settings-actions-inner,
#settings-mobile-tabbar .admin-settings-actions-inner {
grid-column: 1;
}
.admin-settings-keywords {
@@ -5942,6 +6057,31 @@ a.j13-mention-token:hover {
height: auto;
}
}
/* 书库管理:左列表右编辑;桌面整页不滚(复用 admin-content-page 的 canvas 锁定),
两栏各自内部滚动;窄屏按 data-view 切换单栏 */
.admin-library-split {
display: grid;
gap: 0.85rem;
align-items: stretch;
min-height: 18rem;
}
@media (min-width: 1024px) {
.admin-library-split {
grid-template-columns: minmax(15rem, 21rem) minmax(0, 1fr);
gap: 1rem;
flex: 1 1 auto;
min-height: 0;
height: auto;
}
}
@media (max-width: 1023.98px) {
.admin-library-split[data-view="editor"] .admin-library-list {
display: none;
}
.admin-library-split[data-view="list"] .admin-library-editor {
display: none;
}
}
.admin-messages-panel {
flex: 1 1 auto;
min-height: 0;
@@ -6679,7 +6819,7 @@ a.j13-mention-token:hover {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin: 0 0 0.65rem -0.1rem;
margin: 0;
padding: 0.32rem 0.75rem 0.32rem 0.6rem;
border-radius: 999px;
background: var(--ok-soft);
@@ -6691,6 +6831,7 @@ a.j13-mention-token:hover {
line-height: 1.35;
position: relative;
z-index: 1;
flex-shrink: 0;
}
.j13-timeline-day-label time {
color: var(--ok);
@@ -6748,19 +6889,29 @@ a.j13-mention-token:hover {
}
.j13-timeline-head {
display: flex;
align-items: flex-start;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
}
.j13-timeline-summary {
margin: 0;
flex: 1;
flex: 1 1 auto;
min-width: 0;
font-size: 13.5px;
font-weight: 500;
line-height: 1.5;
font-size: 15px;
font-weight: 800;
line-height: 1.4;
color: var(--ink);
letter-spacing: 0.01em;
word-break: break-word;
}
/* 当天非首条:版本号紧贴左侧,与首条版本号对齐(不重复日期) */
.j13-timeline-item:not(.is-first-in-day) .j13-timeline-summary {
margin-left: 0;
}
/* 首条目卡片上方留一点呼吸,与日期胶囊拉开层次 */
.j13-timeline-item.is-first-in-day .j13-timeline-card {
padding-top: 0.15rem;
}
.j13-timeline-chip {
display: inline-flex;
align-items: center;
@@ -7925,7 +8076,7 @@ a.j13-mention-token:hover {
/* 移动端横向板块条(滚动条隐藏,保留触控惯性) */
.j13-chips {
display: flex;
gap: 8px;
gap: 5px;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
@@ -7943,7 +8094,7 @@ a.j13-mention-token:hover {
/* 无框 chip:透明边框占位,选中/悬停切底色时尺寸不跳 */
border: 1px solid transparent;
background: transparent;
border-radius: 999px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
color: var(--ink-2);
@@ -7979,7 +8130,7 @@ a.j13-mention-token:hover {
opacity: 1;
}
.j13-chip-fade-left {
left: -1rem;
left: 0;
border-radius: 1rem 0 0 1rem;
background: linear-gradient(
to right,
@@ -7988,7 +8139,7 @@ a.j13-mention-token:hover {
);
}
.j13-chip-fade-right {
right: -1rem;
right: 0;
border-radius: 0 1rem 1rem 0;
background: linear-gradient(
to left,
@@ -8068,10 +8219,11 @@ a.j13-mention-token:hover {
}
.prose-content .j13-timeline-summary {
margin: 0;
font-size: 13.5px;
font-weight: 500;
line-height: 1.5;
font-size: 15px;
font-weight: 800;
line-height: 1.4;
color: var(--ink);
letter-spacing: 0.01em;
}
.prose-content .j13-timeline-body,
.prose-content .j13-timeline-body p {
@@ -8079,6 +8231,29 @@ a.j13-mention-token:hover {
line-height: 1.55;
color: var(--ink-3);
}
/* 条目卡嵌在 prose 内:压住字段值/摘要的段落与列表外边距(未分层规则才能胜过
.prose-content p 的 0.9rem 外边距),否则字段内容被推低、与左侧标签错位 */
.prose-content .j13-entry-card dd p,
.prose-content .j13-entry-card .j13-entry-summary p {
margin: 0;
}
.prose-content .j13-entry-card dd ul,
.prose-content .j13-entry-card dd ol {
margin: 0.35rem 0;
}
.prose-content .j13-entry-card dd li {
margin: 0.15rem 0;
}
/* 条目卡标题字号:移动端收小(约 h3 水平),sm 起与 prose h2 同大;
卡内 h2 用 inline style 引用该变量,避免被上面的 .prose-content h2 压过 */
.j13-entry-card h2 {
--entry-title-size: 1.18rem;
}
@media (min-width: 640px) {
.j13-entry-card h2 {
--entry-title-size: 1.4rem;
}
}
.prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
.prose-content h2 {
font-size: 1.4rem;
@@ -8619,8 +8794,8 @@ html.dark .prose-content pre[data-theme] span {
前台 main 纸面 + 首页三栏「白纸一体」
main 铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
1px --line 描边与页头下边线围合成框;站长配置了自定义站点
背景图时让位(保持透出画布)。左栏与窄屏下沉面板无框化,
内容直接坐在纸面上;右栏保留卡片板块感(j13-module 原样)。
背景图时让位(保持透出画布)。左栏无框化,内容直接坐在纸面上;
右栏保留卡片板块感(j13-module 原样)。
============================================================ */
html:not(.j13-has-site-bg) #main {
background: var(--main-bg);
@@ -8637,8 +8812,7 @@ html:not(.j13-has-site-bg) #main {
padding: 0;
}
/* 左栏小节分隔:任意块相邻时画发丝线(含站长自定义工具块) */
.j13-rail-left > * + *,
.j13-fluid-home .j13-flat-rail > * + * {
.j13-rail-left > * + * {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid var(--line);
@@ -8654,8 +8828,7 @@ html:not(.j13-has-site-bg) #main {
height: 14px;
}
/* 栏内图标无底色:只留彩色字形本身(收成图标实际大小,避免隐形占位拉大间距) */
.j13-rail-left .j13-bcard-ico,
.j13-fluid-home .j13-flat-rail .j13-bcard-ico {
.j13-rail-left .j13-bcard-ico {
background: transparent !important; /* 覆盖「全部讨论/管理面板」图标的内联底色 */
border-radius: 0;
width: 20px;
@@ -8696,25 +8869,16 @@ html:not(.j13-has-site-bg) #main {
}
}
/* 左栏分组标题:小号灰字,纸面风格的段落断点 */
.j13-rail-left .j13-rail-group,
.j13-fluid-home .j13-flat-rail .j13-rail-group {
.j13-rail-left .j13-rail-group {
padding: 10px 8px 4px;
font-size: 11px;
font-weight: 500;
color: var(--ink-3);
letter-spacing: 0.02em;
}
.j13-rail-left .j13-rail-group:first-child,
.j13-fluid-home .j13-flat-rail .j13-rail-group:first-child {
.j13-rail-left .j13-rail-group:first-child {
padding-top: 2px;
}
/* 手机端下沉面板:推广占位放开到整栏宽(248 定宽是桌面右栏尺寸,
窄屏居中两侧大片空白显得稀疏);/ads/buy 购买页预览保持真实尺寸不受影响 */
@media (max-width: 1023px) {
.j13-fluid-home .j13-flat-rail .j13-ad-promo {
width: 100%;
}
}
/* 左栏板块导航:当前项 accent 竖条 + 浅色底 */
.j13-rail-left .j13-module .j13-bcard {
position: relative;

View File

@@ -67,7 +67,8 @@ export function generateMetadata(): Metadata {
},
description: desc,
keywords,
...(settings.favicon_url ? { icons: { icon: settings.favicon_url } } : {}),
// 固定地址:换图后浏览器/书签侧的 icon 链接不变
...(settings.favicon_url ? { icons: { icon: "/api/brand/favicon" } } : {}),
openGraph: {
title,
description: desc,

View File

@@ -29,6 +29,7 @@ import { requestCanonical } from "@/lib/canonicalMeta";
import { userPath, leaderboardPath, type UrlStyle } from "@/lib/urlStyle";
import Avatar from "@/components/Avatar";
import LevelBadge from "@/components/LevelBadge";
import MobileRailDrawers from "@/components/MobileRailDrawers";
interface PageProps {
searchParams: Promise<{ period?: string; metric?: string }>;
@@ -411,7 +412,7 @@ function RulesPanel({ s }: { s: PublicSettings }) {
))}
</ul>
<p className="meta text-[11.5px] mt-3 leading-relaxed">
累计积分即贡献值,决定等级与总榜排名;消费仅扣余额,不减少累计值。
贡献值 = 周期内累计获得的积分,按上方规则逐笔入账;消费仅扣余额,不减少累计值。
</p>
</section>
);
@@ -442,7 +443,21 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
const totalScore = entries.reduce((acc, e) => acc + e.score, 0);
return (
<div className="grid gap-5 lg:gap-6 lg:grid-cols-[minmax(0,1fr)_320px] items-start">
<>
{/* 移动端:右栏(我的排名 + 贡献值规则)收进右抽屉,lg 起隐藏 */}
<MobileRailDrawers
right={{
label: "排名与规则",
content: (
<>
<MyRankPanel me={me} authed={authed} period={period} metric={metric} />
<RulesPanel s={settings} />
</>
),
}}
/>
<div className="grid gap-5 lg:gap-6 lg:grid-cols-[minmax(0,1fr)_320px] items-start">
{/* 左栏:贡献排行榜 */}
<div className="min-w-0">
{/* Hero:渐变洗底 + 光斑装饰 */}
@@ -592,11 +607,12 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
)}
</div>
{/* 右栏:我的排名 + 贡献值规则 */}
<aside className="space-y-4 lg:sticky lg:top-[76px]">
{/* 右栏:我的排名 + 贡献值规则(移动端收进右抽屉) */}
<aside className="hidden lg:block space-y-4 lg:sticky lg:top-[76px]">
<MyRankPanel me={me} authed={authed} period={period} metric={metric} />
<RulesPanel s={settings} />
</aside>
</div>
</>
);
}

View File

@@ -0,0 +1,261 @@
import type { Metadata } from "next";
import type { CSSProperties } from "react";
import { notFound } from "next/navigation";
import Link from "next/link";
import { BookOpen, Download, Paperclip } from "lucide-react";
import { fetchLibraryDocBySlug, type LibraryFileItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { getPublicSettingsCached } from "@/lib/serverData";
import MarkdownBody from "@/components/MarkdownBody";
import { SiteDocMasthead } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { libraryDocPath, libraryDocReadPath } from "@/lib/urlStyle";
import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
import { estimateReadingMinutes } from "@/lib/toc";
import { groupSections } from "@/lib/librarySections";
import LightboxImage from "@/components/LightboxImage";
import AdaptiveCoverSlot from "@/components/AdaptiveCoverSlot";
interface PageProps {
params: Promise<{ slug: string }>;
}
function formatSize(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 B";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** 无封面时:完整书名 + 书名派生稳定色相渐变块(有图封面走 AdaptiveCoverSlot 按横竖版自适应摆放) */
function CoverFallback({ title }: { title: string }) {
const name = title.trim() || "书";
return (
<div
className="j13-text-cover w-full aspect-[3/4] rounded-2xl flex items-center justify-center px-6 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span className="text-[26px] font-extrabold leading-relaxed text-center line-clamp-5 break-all">
{name}
</span>
</div>
);
}
// 附件仅下载分发(书籍正文走阅读页逐章阅读)
function ExtChip({ ext }: { ext: string }) {
return (
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide shrink-0"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{ext}
</span>
);
}
function FileRow({ f }: { f: LibraryFileItem }) {
return (
<div
className="rounded-xl px-4 py-3"
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<ExtChip ext={f.ext} />
<span
className="text-[13.5px] font-medium min-w-0 break-all"
style={{ color: "var(--ink)" }}
>
{f.name}
</span>
<span className="meta text-[11.5px]">
{formatSize(f.size)} · {f.download_count} 次下载
</span>
<a
href={`/api/library/files/${f.id}/download`}
className="btn btn-sm btn-primary ml-auto inline-flex items-center gap-1.5 shrink-0"
>
<Download size={13} /> 下载
</a>
</div>
</div>
);
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const data = await fetchLibraryDocBySlug(slug).catch(() => null);
if (!data) return { title: "条目不存在" };
const d = data.doc;
return {
title: d.title,
description: d.description ? d.description.slice(0, 150) : d.title,
openGraph: {
title: d.title,
description: d.description ? d.description.slice(0, 150) : d.title,
type: "article",
publishedTime: d.created_at,
},
...(await requestCanonical(libraryDocPath((await getPublicSettingsCached()).url_style, d.slug))),
};
}
export default async function LibraryDocPage({ params }: PageProps) {
const { slug } = await params;
const [data, settings] = await Promise.all([
fetchLibraryDocBySlug(slug).catch(() => null),
getPublicSettingsCached(),
]);
if (!data) notFound();
const d = data.doc;
const urlStyle = settings.url_style;
const chapterCount = groupSections(d.sections ?? []).length;
const readingMinutes = estimateReadingMinutes(
[d.description, ...(d.sections ?? []).map((s) => s.content)].filter(Boolean).join("\n\n")
);
const jsonLd = webPageJsonLd({
name: d.title,
description: d.description ? d.description.slice(0, 150) : undefined,
path: libraryDocPath(urlStyle, d.slug),
datePublished: d.created_at,
dateModified: d.updated_at,
});
return (
<>
<JsonLd data={jsonLd} />
<div className="max-w-[1200px] 2xl:max-w-[1320px] mx-auto min-w-0">
{/* 手机端水平内边距收窄(px-3),介绍内容近全宽;sm 起恢复常规面板内距 */}
<article className="panel px-3 py-5 sm:p-8">
<SiteDocMasthead
kicker="书库"
title={d.title}
createdAt={d.created_at}
updatedAt={d.updated_at}
showTimeOfDay
readingMinutes={readingMinutes}
badges={
chapterCount > 0 ? (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11.5px] font-semibold"
style={{ background: "var(--panel-2)", color: "var(--ink-2)", border: "1px solid var(--line)" }}
>
{chapterCount} 章
</span>
) : undefined
}
/>
{/* 横版封面(宽>高且足够大):全宽置于内容上方;竖版或过小的横版图时此槽位不渲染。
小图不强行放大占满全宽:max-w-full 下保持自然尺寸,容器随内容收缩并水平居中 */}
{d.cover_url ? (
<AdaptiveCoverSlot orientation="landscape" src={d.cover_url}>
<div
className="mt-6 w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
style={{ border: "1px solid var(--line)" }}
>
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
</div>
</AdaptiveCoverSlot>
) : null}
<div className="mt-6 lg:grid lg:grid-cols-[minmax(0,340px)_minmax(0,1fr)] lg:gap-8">
{/* 左栏:封面 + 元信息 + 开始阅读 */}
<aside className="lg:sticky lg:top-[84px] self-start">
{d.cover_url ? (
// 竖版封面(长>宽)及过小的横版封面:按图片自然比例显示在左栏;
// 小图不强行放大占满:保持自然尺寸,在槽位最大区域内水平垂直居中
<AdaptiveCoverSlot orientation="portrait" src={d.cover_url}>
<div
className="w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
style={{ border: "1px solid var(--line)" }}
>
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
</div>
</AdaptiveCoverSlot>
) : (
<CoverFallback title={d.title} />
)}
<dl className="mt-4 flex flex-col gap-2 text-[13px]">
<div className="flex items-center justify-between">
<dt className="meta">章节数</dt>
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{chapterCount}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="meta">附件数</dt>
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{d.files.length}
</dd>
</div>
</dl>
{chapterCount > 0 ? (
<Link
href={libraryDocReadPath(urlStyle, d.slug)}
className="btn btn-primary mt-4 w-full justify-center inline-flex items-center gap-2"
>
<BookOpen size={15} aria-hidden /> 开始阅读
</Link>
) : null}
</aside>
{/* 右栏:介绍 + 附件 */}
<div className="mt-6 lg:mt-0 min-w-0">
<section>
<h2
className="text-[15px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Paperclip size={15} style={{ color: "var(--accent)" }} aria-hidden />
介绍
</h2>
<div className="mt-3">
{d.description ? (
<MarkdownBody content={d.description} />
) : (
<p className="meta text-[13px]">这本书还没有介绍。</p>
)}
</div>
</section>
<section id="files" className="mt-9 scroll-mt-24">
<h2
className="text-[15px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Download size={15} style={{ color: "var(--accent)" }} aria-hidden />
附件下载({d.files.length})
</h2>
{d.files.length === 0 ? (
<p className="meta text-[12.5px] mt-3">暂无附件</p>
) : (
<div className="mt-3 flex flex-col gap-2.5">
{d.files.map((f) => (
<FileRow key={f.id} f={f} />
))}
</div>
)}
</section>
</div>
</div>
</article>
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
<BookOpen size={14} style={{ color: "var(--accent)" }} aria-hidden />
<span>来自站点书库</span>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,360 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, ArrowRight } from "lucide-react";
import type { Components } from "react-markdown";
import {
fetchLibraryDocBySlug,
type LibraryDoc,
type LibrarySectionItem,
} from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import MarkdownBody from "@/components/MarkdownBody";
import { libraryDocPath, libraryDocReadPath } from "@/lib/urlStyle";
import {
chapterNav,
groupSections,
type ChapterNav,
} from "@/lib/librarySections";
import { extractToc, estimateReadingMinutes, markdownHeadingToc, buildTocFromSegments } from "@/lib/toc";
import { splitContentSegments } from "@/lib/directiveBlocks";
import { wrapImplicitEntries } from "@/lib/entryBlocks";
import PostToc, { type TocItem } from "@/components/PostToc";
import MobileRailDrawers from "@/components/MobileRailDrawers";
import { formatRelative } from "@/lib/format";
interface PageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ c?: string }>;
}
type SectionGroups = ReturnType<typeof groupSections>;
interface ReadData {
doc: LibraryDoc;
groups: SectionGroups;
current: LibrarySectionItem | null;
nav: ChapterNav;
}
/**
* 读页共享取数(generateMetadata 与 page 共用):
* fetch 失败/404 → null;?c 为书内章序号(1 起,可写入文章目录地址),
* 越界/缺省回落第一章;旧链接传章节全局 id 时兜底按 id 匹配(不报错)
*/
async function loadRead(slug: string, cParam?: string): Promise<ReadData | null> {
const data = await fetchLibraryDocBySlug(slug).catch(() => null);
if (!data) return null;
const groups = groupSections(data.doc.sections ?? []);
const c = Number.parseInt(cParam ?? "", 10);
const byNo =
Number.isFinite(c) && c >= 1 && c <= groups.length ? groups[c - 1].chapter : undefined;
const byId = Number.isFinite(c) ? groups.find((g) => g.chapter.id === c)?.chapter : undefined;
const current = byNo ?? byId ?? groups[0]?.chapter ?? null;
return { doc: data.doc, groups, current, nav: chapterNav(groups, current?.id ?? null) };
}
/** 为内容块的 markdown 标题挂章内锚点 id(ids 按标题出现顺序对应,与本章目录共享同一份) */
function headingComponentsFor(content: string, ids: string[]): Components | undefined {
const toc = markdownHeadingToc(extractToc(content || ""));
if (toc.length === 0) return undefined;
let idx = 0;
const render = (Tag: "h2" | "h3") =>
function MdHeading({ children }: { children?: React.ReactNode }) {
const item = toc[idx++];
return (
<Tag id={item ? ids[idx - 1] : undefined} style={{ scrollMarginTop: 90 }}>
{children}
</Tag>
);
};
return { h2: render("h2"), h3: render("h3") };
}
export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
const { slug } = await params;
const { c } = await searchParams;
const data = await loadRead(slug, c);
if (!data) return { title: "条目不存在" };
const title = data.current ? `${data.current.title} · ${data.doc.title}` : data.doc.title;
const description = (data.current?.content || data.doc.title).slice(0, 150);
// 全部章节共用一个 URL,避免重复收录;详情页是唯一索引入口
return { title, description, robots: { index: false } };
}
export default async function LibraryReadPage({ params, searchParams }: PageProps) {
const [{ slug }, { c }] = await Promise.all([params, searchParams]);
const [data, settings] = await Promise.all([loadRead(slug, c), getPublicSettingsCached()]);
if (!data) notFound();
const urlStyle = settings.url_style;
const d = data.doc;
// 无章节条目:阅读页无内容可渲染,回详情页(介绍 + 附件)
if (!data.current) redirect(libraryDocPath(urlStyle, d.slug));
const current = data.current;
const { groups, nav } = data;
const subs = groups[nav.index]?.subs ?? [];
// 章内锚点统一 e-{sec}-{n}:sec 为书内章序号(与地址 ?sec= 一致),n 为章内 1 基顺序号,
// 按渲染顺序(章首 markdown 标题/条目卡标题 → 各小节及其标题/条目)由同一计数器分配,
// 目录、正文 markdown 标题、卡内条目标题共享同一份 id,可手写目录链接如 ?sec=1#e-1-1
// 条目卡模式先做与渲染一致的规范化(标题包壳后再提取),避免目录锚点错位
const normOf = (content: string) =>
d.entries_auto ? wrapImplicitEntries(content || "") : content || "";
const segsOf = (content: string) =>
splitContentSegments(normOf(content), { entriesAuto: d.entries_auto });
const secNo = nav.index + 1;
let anchorSeq = 0;
const nextAnchor = () => `e-${secNo}-${++anchorSeq}`;
const chToc = buildTocFromSegments(segsOf(current.content), nextAnchor);
// 小节:titleId 仍由页面 h3 直接挂载,其内容目录来自分段构建
const subAnchors = subs.map((sub) => ({
titleId: nextAnchor(),
toc: buildTocFromSegments(segsOf(sub.content), nextAnchor),
}));
// 右栏目录不重复章名(正文标题、左栏章节列表均已可见),章首条目/小节/小节内标题直接铺开,
// 省掉章名占位行,让有限宽度的每行能显示更长的标题
const chapterToc: TocItem[] = [...chToc.items];
subAnchors.forEach((a, si) => {
chapterToc.push({ level: 2, text: subs[si].title, id: a.titleId });
chapterToc.push(...a.toc.items);
});
const hasChapterToc = chapterToc.length > 0;
const detail = libraryDocPath(urlStyle, d.slug);
const chapterMinutes = estimateReadingMinutes(
[current.content, ...subs.map((s) => s.content)].filter(Boolean).join("\n\n")
);
// 书籍侧栏内容:桌面左栏与移动端左抽屉共用(详情入口 + 章节列表)
const bookRail = (
<div className="flex flex-col gap-3">
<Link
href={detail}
className="inline-flex items-center gap-1.5 meta text-[12px] shrink-0 hover:text-[var(--accent)]"
>
<ArrowLeft size={13} aria-hidden /> 书籍详情
</Link>
<Link
href={detail}
className="block rounded-xl p-1 -m-1 hover:bg-[var(--panel-2)] transition-colors"
>
<div className="min-w-0">
<p
className="text-[13.5px] font-bold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{d.title}
</p>
<p className="meta text-[11.5px] mt-1">
{nav.total} 章 · 更新于 {formatRelative(d.updated_at)}
</p>
</div>
</Link>
<nav aria-label="章节目录">
<p
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2 px-1"
style={{ color: "var(--ink-3)" }}
>
目录
</p>
<ul className="space-y-0.5" style={{ borderLeft: "1px solid var(--line)" }}>
{groups.map((g, i) => {
const active = g.chapter.id === current.id;
return (
<li key={g.chapter.id}>
<Link
href={libraryDocReadPath(urlStyle, d.slug, i + 1)}
className={`j13-toc-link${active ? " is-active" : ""}`}
aria-current={active ? "location" : undefined}
>
<span className="flex items-center gap-1.5 min-w-0">
<span className="min-w-0 flex-1 truncate">{g.chapter.title}</span>
</span>
</Link>
{/* 当前章的节:页内锚点(e-{sec}-{n});其他章的节:跳到该章 */}
{g.subs.map((sub, si) =>
active ? (
<a key={sub.id} href={`#${subAnchors[si].titleId}`} className="j13-toc-link is-h3">
{sub.title}
</a>
) : (
<Link
key={sub.id}
href={libraryDocReadPath(urlStyle, d.slug, i + 1)}
className="j13-toc-link is-h3"
>
{sub.title}
</Link>
)
)}
</li>
);
})}
</ul>
</nav>
</div>
);
return (
<div className="min-w-0">
{/* 移动端:吸顶工具栏 + 左右抽屉(目录 / 本章),lg 起隐藏;返回详情入口在左抽屉内 */}
<MobileRailDrawers
center={
<>
<p className="truncate text-[13px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
{current.title}
</p>
<p className="meta mt-0.5 text-[10.5px] leading-tight">
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
</p>
</>
}
left={{ label: "目录", content: bookRail }}
right={
hasChapterToc
? {
label: "本章",
// 抽屉容器是无高度约束的滚动区,需包一层 h-full 限高 flex,
// PostToc 内列表才能成为滚动容器:否则列表不滚动(overscroll-behavior
// 阻断滚动链到抽屉容器)且跟随阅读位置的定位滚动落空
content: (
<div className="flex h-full min-h-0 flex-col">
<PostToc items={chapterToc} title="本章目录" bare />
</div>
),
}
: undefined
}
/>
<div
className={
hasChapterToc
? "lg:grid lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-8 xl:grid-cols-[280px_minmax(0,1fr)_280px] xl:gap-6"
: "lg:grid lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)] lg:gap-8"
}
>
{/* 左侧书栏:详情入口 + 章节目录树(与移动端左抽屉共用 bookRail 内容) */}
<aside className="hidden lg:block min-w-0">
<div className="sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] overflow-y-auto j13-rail-scroll panel p-4">
{bookRail}
</div>
</aside>
{/* 中间:单章正文 + 上下章导航(占满中栏全宽;手机端 panel 贴边铺满) */}
<div className="j13-doc-bleed min-w-0">
<article className="panel p-6 sm:p-8">
<header>
<p className="meta text-[12px]">
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
</p>
<h2
id="ch-top"
className="mt-1.5 scroll-mt-24 text-[24px] sm:text-[26px] font-extrabold leading-snug"
style={{ color: "var(--ink)" }}
>
{current.title}
</h2>
</header>
{current.content ? (
<div className="mt-5">
<MarkdownBody
content={current.content}
components={headingComponentsFor(normOf(current.content), chToc.mdHeadIds)}
entriesAuto={d.entries_auto}
entriesTitleIds={chToc.entriesTitleIds}
/>
</div>
) : null}
{subs.map((sub, si) => (
<section key={sub.id} className="mt-8">
<h3
id={subAnchors[si].titleId}
className="scroll-mt-24 text-[17px] sm:text-[18px] font-bold"
style={{ color: "var(--ink)" }}
>
{sub.title}
</h3>
{sub.content ? (
<div className="mt-3">
<MarkdownBody
content={sub.content}
components={headingComponentsFor(normOf(sub.content), subAnchors[si].toc.mdHeadIds)}
entriesAuto={d.entries_auto}
entriesTitleIds={subAnchors[si].toc.entriesTitleIds}
/>
</div>
) : null}
</section>
))}
</article>
<nav className="mt-5 grid gap-3 sm:grid-cols-2" aria-label="章节导航">
{nav.prev ? (
<Link
href={libraryDocReadPath(urlStyle, d.slug, nav.index)}
className="panel p-4 min-w-0 hover:bg-[var(--panel-2)] transition-colors"
>
<span className="meta text-[11.5px] inline-flex items-center gap-1">
<ArrowLeft size={12} aria-hidden /> 上一章
</span>
<span
className="mt-1 block text-[13.5px] font-semibold truncate"
style={{ color: "var(--ink)" }}
>
{nav.prev.title}
</span>
</Link>
) : (
<div className="panel p-4 opacity-50 select-none">
<span className="meta text-[11.5px]">上一章</span>
<span className="mt-1 block text-[13.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
已是第一章
</span>
</div>
)}
{nav.next ? (
<Link
href={libraryDocReadPath(urlStyle, d.slug, nav.index + 2)}
className="panel p-4 min-w-0 hover:bg-[var(--panel-2)] transition-colors sm:text-right"
>
<span className="meta text-[11.5px] inline-flex items-center gap-1">
下一章 <ArrowRight size={12} aria-hidden />
</span>
<span
className="mt-1 block text-[13.5px] font-semibold truncate"
style={{ color: "var(--ink)" }}
>
{nav.next.title}
</span>
</Link>
) : (
<div className="panel p-4 opacity-50 select-none sm:text-right">
<span className="meta text-[11.5px]">下一章</span>
<span className="mt-1 block text-[13.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
已是最后一章
</span>
</div>
)}
</nav>
</div>
{/* 右栏:章节内目录树(区域足够时宽屏常驻,滚动时可随时跳转)。
外层仅做 sticky 定位(flex-col + max-h 限高),滚动由 PostToc 内列表承担:
避免外层 overflow-y:auto 与内层列表的 overflow-y:auto 形成双层滚动容器,
滚轮路由到不可滚的内层导致滚轮失效(只能拖滚动条) */}
{hasChapterToc ? (
<aside className="hidden xl:block min-w-0">
<div className="sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] flex flex-col">
<PostToc items={chapterToc} title="本章目录" bare />
</div>
</aside>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import type { Metadata } from "next";
import type { CSSProperties } from "react";
import Link from "next/link";
import { BookOpen, FileDown } from "lucide-react";
import { fetchLibraryList, type LibraryDocListItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { getPublicSettingsCached } from "@/lib/serverData";
import { requestCanonical } from "@/lib/canonicalMeta";
import { libraryPath, libraryDocPath } from "@/lib/urlStyle";
function formatSize(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 B";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** 文字封面:无图时用完整书名 + 书名派生稳定色相洗底(lib/coverColor.ts) */
function TextCover({ title }: { title: string }) {
const name = title.trim() || "书";
return (
<div
className="j13-text-cover w-full h-full flex items-center justify-center px-5 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span
className="text-[22px] font-extrabold leading-snug text-center line-clamp-3 break-all"
>
{name}
</span>
</div>
);
}
function ExtChip({ ext }: { ext: string }) {
return (
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{ext}
</span>
);
}
function DocCard({
doc,
href,
}: {
doc: LibraryDocListItem;
href: string;
}) {
const exts = Array.from(new Set(doc.files.map((f) => f.ext))).slice(0, 4);
const totalSize = doc.files.reduce((acc, f) => acc + f.size, 0);
return (
<Link
href={href}
className="group flex flex-col overflow-hidden rounded-[20px] transition-transform hover:-translate-y-0.5"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
<div className="h-36 overflow-hidden" style={{ borderBottom: "1px solid var(--line)" }}>
{doc.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={doc.cover_url}
alt={doc.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<TextCover title={doc.title} />
)}
</div>
<div className="p-4 flex flex-col gap-2 flex-1">
<h2
className="text-[15px] font-bold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{doc.title}
</h2>
<div className="mt-auto flex flex-wrap items-center gap-1.5">
{exts.map((ext) => (
<ExtChip key={ext} ext={ext} />
))}
<span className="meta text-[11px] ml-auto inline-flex items-center gap-1">
<FileDown size={11} />
{doc.file_count > 0 ? `${doc.file_count} 个文件 · ${formatSize(totalSize)}` : "暂无文件"}
</span>
</div>
</div>
</Link>
);
}
export async function generateMetadata(): Promise<Metadata> {
const settings = await getPublicSettingsCached();
return {
title: "书库",
description: settings.site_description,
...(await requestCanonical(libraryPath(settings.url_style))),
};
}
export default async function LibraryPage() {
const settings = await getPublicSettingsCached();
const urlStyle = settings.url_style;
// 接口失败降级为空态,不让页面 500
let docs: LibraryDocListItem[] = [];
try {
const res = await fetchLibraryList();
docs = res.docs ?? [];
} catch {
/* 降级空态 */
}
return (
<div className="min-w-0">
{/* Hero:渐变洗底(与排行榜同语言) */}
<section
className="relative overflow-hidden rounded-[28px] px-6 py-8 sm:px-10 sm:py-10 text-center"
style={{
background:
"radial-gradient(120% 160% at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 55%)," +
"radial-gradient(90% 140% at 90% -20%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 50%)",
border: "1px solid var(--line)",
}}
>
<div
aria-hidden
className="absolute -top-16 -right-10 w-56 h-56 rounded-full pointer-events-none"
style={{
background: "color-mix(in srgb, var(--accent) 14%, transparent)",
filter: "blur(48px)",
}}
/>
<div className="relative">
<h1
className="text-[26px] sm:text-[32px] leading-tight font-extrabold tracking-tight inline-flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<BookOpen size={26} style={{ color: "var(--accent)" }} />
书库
</h1>
<p className="meta mt-2 text-[13.5px]">书籍、文档与页面作品,在线阅读或下载</p>
</div>
</section>
{docs.length === 0 ? (
<div
className="mt-6 rounded-[20px] px-6 py-14 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[14px] font-medium" style={{ color: "var(--ink-2)" }}>
书库还没有内容
</p>
<p className="meta text-[12.5px] mt-1.5">管理员可在后台「书库管理」发布条目</p>
</div>
) : (
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{docs.map((d) => (
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,506 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Info, Link2, Loader2, X } from "lucide-react";
import {
apiMyFriendLinks,
apiUpdateMyFriendLink,
type FriendLink,
type FriendLinkStatus,
} from "@/lib/api";
import LinksClient from "./LinksClient";
interface Props {
links: FriendLink[];
isLoggedIn: boolean;
canManage: boolean;
/** 本站信息(申请弹窗展示,供对方添加回链) */
siteName: string;
siteUrl: string;
siteLogo: string;
}
/** 申请须知(站点政策,可按需修改) */
const APPLY_NOTICE = "纯导链、无回链、带有营销类产品的网站申请将不予通过";
/** 弹窗内单条申请的可编辑字段 */
interface MineEdit {
name: string;
url: string;
reciprocal_url: string;
logo_url: string;
}
function toEdit(l: FriendLink): MineEdit {
return {
name: l.name,
url: l.url,
reciprocal_url: l.reciprocal_url || "",
logo_url: l.logo_url || "",
};
}
/** 取 URL 域名用于搜索匹配(解析失败返回原文) */
function domainOf(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return url.toLowerCase();
}
}
const STATUS_META: Record<FriendLinkStatus, { label: string; color: string }> = {
pending: { label: "待审核", color: "var(--accent)" },
approved: { label: "已通过", color: "#16a34a" },
rejected: { label: "已拒绝", color: "var(--danger)" },
};
export default function LinksBoard({
links,
isLoggedIn,
canManage,
siteName,
siteUrl,
siteLogo,
}: Props) {
const router = useRouter();
const [query, setQuery] = useState("");
const [mineOpen, setMineOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
const [mine, setMine] = useState<FriendLink[] | null>(null);
const [mineLoading, setMineLoading] = useState(false);
const [edits, setEdits] = useState<Record<number, MineEdit>>({});
const [savingId, setSavingId] = useState<number | null>(null);
const [editMsg, setEditMsg] = useState<{ id: number; ok: boolean; text: string } | null>(null);
// 从其他入口带 #apply 锚点跳转过来时(如首页侧栏「申请」),自动打开申请弹窗
useEffect(() => {
if (window.location.hash === "#apply") setApplyOpen(true);
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return links;
return links.filter(
(l) => l.name.toLowerCase().includes(q) || domainOf(l.url).includes(q)
);
}, [links, query]);
const openMine = async () => {
setMineOpen(true);
if (mine == null) {
setMineLoading(true);
try {
const res = await apiMyFriendLinks();
const list = res.links ?? [];
setMine(list);
setEdits((e) => {
const next = { ...e };
for (const l of list) if (!next[l.id]) next[l.id] = toEdit(l);
return next;
});
} catch {
setMine([]);
} finally {
setMineLoading(false);
}
}
};
const setEdit = (id: number, k: keyof MineEdit, v: string) =>
setEdits((e) => ({ ...e, [id]: { ...e[id], [k]: v } }));
const saveMine = async (l: FriendLink) => {
const edit = edits[l.id];
if (!edit || savingId != null) return;
setSavingId(l.id);
setEditMsg(null);
try {
await apiUpdateMyFriendLink(l.id, {
name: edit.name.trim(),
url: edit.url.trim(),
reciprocal_url: edit.reciprocal_url.trim() || undefined,
logo_url: edit.logo_url.trim() || undefined,
});
// 重拉本人申请列表;已通过/已驳回的修改会回退待审,同时刷新 SSR 卡片墙
const res = await apiMyFriendLinks();
const list = res.links ?? [];
setMine(list);
setEdits((e) => {
const next = { ...e };
for (const nl of list) next[nl.id] = toEdit(nl);
return next;
});
const updated = list.find((x) => x.id === l.id);
setEditMsg({
id: l.id,
ok: true,
text:
updated && updated.status === "pending" && l.status !== "pending"
? "已保存,链接重新进入审核队列"
: "保存成功",
});
router.refresh();
} catch (err) {
setEditMsg({
id: l.id,
ok: false,
text: err instanceof Error ? err.message : "保存失败,请重试",
});
} finally {
setSavingId(null);
}
};
return (
<>
{/* 搜索 */}
<div className="mx-auto max-w-md">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索友链(域名或名称)"
className="w-full rounded-full px-4 py-2 text-[14px] outline-none transition-colors"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
/>
</div>
{/* 操作按钮 */}
<div className="mt-4 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setApplyOpen(true)}
className="inline-flex items-center rounded-lg px-4 py-2 text-[13.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: "var(--accent)", color: "#fff" }}
>
申请友链
</button>
<button
type="button"
onClick={openMine}
className="inline-flex items-center rounded-lg px-4 py-2 text-[13.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: "var(--ink)", color: "var(--panel)" }}
>
我的申请
</button>
</div>
{/* 我的申请弹窗:列出本人提交的申请,可直接修改并保存 */}
{mineOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.5)" }}
onClick={() => setMineOpen(false)}
>
<div
className="panel w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl"
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<h3 className="text-[16px] font-bold" style={{ color: "var(--ink)" }}>
我的申请
</h3>
<button
type="button"
onClick={() => setMineOpen(false)}
className="rounded-full p-1 transition-colors hover:bg-[var(--panel-2)]"
aria-label="关闭"
>
<X size={18} style={{ color: "var(--danger)" }} />
</button>
</div>
<div className="p-5">
{!isLoggedIn ? (
<p className="meta py-6 text-center text-[13px]">
请先 <Link href="/login" className="underline hover:text-[var(--accent)]">登录</Link> 后查看申请记录
</p>
) : mineLoading ? (
<p className="meta flex items-center justify-center gap-2 py-6 text-[13px]">
<Loader2 size={14} className="animate-spin" /> 加载中…
</p>
) : mine == null || mine.length === 0 ? (
<p className="meta py-6 text-center text-[13px]">暂无申请记录</p>
) : (
<ul className="space-y-4">
{mine.map((l) => {
const edit = edits[l.id] ?? toEdit(l);
return (
<li
key={l.id}
className="rounded-xl p-4"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
{/* 头部:LOGO + 名称 + 状态 */}
<div className="flex items-center gap-3">
{edit.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={edit.logo_url}
alt=""
width={40}
height={40}
className="rounded-lg object-contain shrink-0"
style={{ width: 40, height: 40, background: "var(--panel)" }}
/>
) : (
<span
className="flex shrink-0 items-center justify-center rounded-lg"
style={{ width: 40, height: 40, background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Link2 size={16} />
</span>
)}
<span className="min-w-0 flex-1 truncate text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
{l.name}
</span>
<span
className="shrink-0 rounded-full px-2.5 py-1 text-[11.5px] font-medium"
style={{
color: STATUS_META[l.status]?.color ?? "var(--ink-3)",
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
{STATUS_META[l.status]?.label ?? l.status}
</span>
</div>
{l.status === "rejected" && l.reject_reason ? (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
拒绝理由:{l.reject_reason}
</p>
) : null}
{/* 可编辑字段 */}
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="meta block text-[12px]">名称</span>
<input
value={edit.name}
maxLength={64}
onChange={(e) => setEdit(l.id, "name", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
/>
</label>
<label className="block">
<span className="meta block text-[12px]">链接</span>
<input
type="url"
value={edit.url}
maxLength={512}
onChange={(e) => setEdit(l.id, "url", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
/>
</label>
</div>
<label className="mt-3 block">
<span className="meta block text-[12px]">友链地址</span>
<input
type="url"
value={edit.reciprocal_url}
maxLength={512}
onChange={(e) => setEdit(l.id, "reciprocal_url", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
placeholder="添加本站链接的页面"
/>
</label>
<label className="mt-3 block">
<span className="meta block text-[12px]">图片</span>
<input
type="url"
value={edit.logo_url}
maxLength={512}
onChange={(e) => setEdit(l.id, "logo_url", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
placeholder="LOGO图片地址"
/>
</label>
{/* 底部:反馈 + 保存 */}
<div className="mt-3 flex items-center justify-end gap-3">
{editMsg?.id === l.id ? (
<span
className="mr-auto text-[12px]"
style={{ color: editMsg.ok ? "var(--accent)" : "var(--danger)" }}
>
{editMsg.text}
</span>
) : l.status !== "pending" ? (
<span className="meta mr-auto text-[11.5px]">修改后将重新进入审核</span>
) : null}
<button
type="button"
onClick={() => saveMine(l)}
disabled={savingId === l.id}
className="btn btn-primary btn-sm"
>
{savingId === l.id ? <Loader2 size={13} className="animate-spin" /> : null}
保存修改
</button>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
) : null}
{/* 申请友链弹窗:本站信息 + 申请表单 */}
{applyOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.5)" }}
onClick={() => setApplyOpen(false)}
>
<div
className="panel w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl"
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<h3 className="text-[16px] font-bold" style={{ color: "var(--ink)" }}>
申请友链
</h3>
<button
type="button"
onClick={() => setApplyOpen(false)}
className="rounded-full p-1 transition-colors hover:bg-[var(--panel-2)]"
aria-label="关闭"
>
<X size={18} style={{ color: "var(--danger)" }} />
</button>
</div>
<div className="p-5">
{/* 本站信息:供对方先添加我方链接 */}
<div
className="rounded-xl p-4"
style={{ background: "var(--accent-soft)", border: "1px solid var(--line)" }}
>
<h4 className="text-[14px] font-bold" style={{ color: "var(--ink)" }}>
本站信息(请添加本站链接后再申请)
</h4>
<dl className="mt-2.5 space-y-1.5 text-[13px]" style={{ color: "var(--ink)" }}>
<div className="flex gap-2">
<dt className="shrink-0 font-semibold">名称:</dt>
<dd className="min-w-0 break-all">{siteName}</dd>
</div>
{siteUrl ? (
<div className="flex gap-2">
<dt className="shrink-0 font-semibold">地址:</dt>
<dd className="min-w-0 break-all">
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[var(--accent)]"
>
{siteUrl}
</a>
</dd>
</div>
) : null}
{siteLogo ? (
<div className="flex gap-2">
<dt className="shrink-0 font-semibold">LOGO:</dt>
<dd className="min-w-0 break-all">
{siteLogo}
<span className="ml-1 text-[12px]" style={{ color: "var(--ink-2)" }}>
(固定地址,换图不失效)
</span>
</dd>
</div>
) : null}
</dl>
</div>
{/* 申请须知 */}
<p
className="mt-3 flex items-start gap-2 rounded-lg px-3.5 py-2.5 text-[12.5px] leading-relaxed"
style={{
background: "var(--panel-2)",
borderLeft: "3px solid var(--accent)",
color: "var(--ink-2)",
}}
>
<Info size={15} style={{ color: "var(--accent)", flexShrink: 0, marginTop: 2 }} aria-hidden />
<span>{APPLY_NOTICE}</span>
</p>
<LinksClient
isLoggedIn={isLoggedIn}
canManage={canManage}
onClose={() => setApplyOpen(false)}
/>
</div>
</div>
</div>
) : null}
{/* 卡片墙 */}
{filtered.length === 0 ? (
<p className="meta mt-8 text-center text-[13.5px]">
{links.length === 0 ? "暂无友链,欢迎申请收录" : "没有匹配的友链"}
</p>
) : (
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{filtered.map((l) => (
<a
key={l.id}
href={l.url}
target="_blank"
rel="noopener noreferrer"
title={l.description || l.name}
className="flex flex-col items-center justify-center gap-2.5 rounded-xl px-3 py-5 transition-all hover:-translate-y-0.5"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
minHeight: 116,
}}
>
{l.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
alt=""
loading="lazy"
width={48}
height={48}
className="rounded-lg object-contain"
style={{ width: 48, height: 48 }}
/>
) : (
<span
className="flex items-center justify-center rounded-lg"
style={{ width: 48, height: 48, background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Link2 size={22} />
</span>
)}
<span className="max-w-full truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{l.name}
</span>
</a>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import { useRef, useState } from "react";
import Link from "next/link";
import { Loader2, Upload } from "lucide-react";
import { apiApplyFriendLink, apiUploadPostImage } from "@/lib/api";
interface Props {
isLoggedIn: boolean;
canManage: boolean;
/** 关闭所在弹窗(取消按钮);弹窗外渲染时可不传 */
onClose?: () => void;
}
interface FormState {
name: string;
url: string;
reciprocal_url: string;
logo_url: string;
}
const EMPTY_FORM: FormState = {
name: "",
url: "",
reciprocal_url: "",
logo_url: "",
};
const FIELD_CLASS =
"w-full rounded-lg px-3 py-2 text-[14px] outline-none transition-colors";
const FIELD_STYLE = {
background: "var(--panel-2)",
color: "var(--ink)",
border: "1px solid var(--line)",
};
export default function LinksClient({ isLoggedIn, canManage, onClose }: Props) {
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [submitting, setSubmitting] = useState(false);
const [uploading, setUploading] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
if (!isLoggedIn) {
return (
<p className="meta mt-3">
请先 <Link href="/login" className="underline hover:text-[var(--accent)]">登录</Link> 后申请友情链接。
</p>
);
}
const set = (k: keyof FormState, v: string) =>
setForm((f) => ({ ...f, [k]: v }));
const onPickFile = async (file: File | undefined) => {
if (!file || uploading) return;
setUploading(true);
setMsg(null);
try {
const res = await apiUploadPostImage(file, file.name);
if (res.url) {
set("logo_url", res.url);
} else {
setMsg({ ok: false, text: res.error || "上传失败,请稍后重试" });
}
} catch {
setMsg({ ok: false, text: "上传失败,请稍后重试" });
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (submitting) return;
setMsg(null);
setSubmitting(true);
try {
await apiApplyFriendLink({
name: form.name.trim(),
url: form.url.trim(),
reciprocal_url: form.reciprocal_url.trim() || undefined,
logo_url: form.logo_url.trim() || undefined,
});
setMsg({ ok: true, text: "申请已提交,待管理员审核通过后展示。" });
setForm(EMPTY_FORM);
} catch (e) {
setMsg({ ok: false, text: e instanceof Error ? e.message : "提交失败,请重试" });
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={onSubmit} className="mt-5 space-y-4">
<label className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
网站名称
</span>
<input
required
maxLength={64}
value={form.name}
onChange={(e) => set("name", e.target.value)}
className={`${FIELD_CLASS} mt-1.5`}
style={FIELD_STYLE}
placeholder="请输入您的网站名称"
/>
</label>
<label className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
网站链接
</span>
<input
required
type="url"
maxLength={512}
value={form.url}
onChange={(e) => set("url", e.target.value)}
className={`${FIELD_CLASS} mt-1.5`}
style={FIELD_STYLE}
placeholder="请输入您的网站地址(以http开头)"
/>
</label>
<label className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
添加我方链接的页面地址
</span>
<input
type="url"
maxLength={512}
value={form.reciprocal_url}
onChange={(e) => set("reciprocal_url", e.target.value)}
className={`${FIELD_CLASS} mt-1.5`}
style={FIELD_STYLE}
placeholder="如:https://您的域名/link.htm(非首页时必填)"
/>
<span className="meta mt-1.5 block text-[12px]">
若友链不在首页,请填写此页面地址,方便管理员核对是否添加了我方链接
</span>
</label>
<div className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
填写或上传本站LOGO
</span>
<div className="mt-1.5 flex items-stretch gap-2">
{/* 预览 */}
<span
className="flex w-16 shrink-0 items-center justify-center rounded-lg text-[11px] meta overflow-hidden"
style={{ border: "1px dashed var(--line)", background: "var(--panel-2)" }}
aria-hidden
>
{form.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.logo_url}
alt=""
className="h-full w-full object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
"预览"
)}
</span>
<input
type="url"
maxLength={512}
value={form.logo_url}
onChange={(e) => set("logo_url", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
placeholder="LOGO图片地址"
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="btn btn-line shrink-0 whitespace-nowrap"
>
{uploading ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
上传
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => onPickFile(e.target.files?.[0])}
/>
</div>
</div>
{msg ? (
<p
className="text-[13px] rounded-lg px-3 py-2"
style={{
background: msg.ok ? "var(--accent-soft)" : "var(--panel-2)",
color: msg.ok ? "var(--accent)" : "var(--ink)",
border: "1px solid var(--line)",
}}
>
{msg.text}
</p>
) : null}
<div className="flex items-center gap-3 pt-1">
<p className="meta flex-1 text-[12px]">
提交后进入待审核状态,可在「我的申请」中查看进度或修改
</p>
<button
type="button"
className="btn btn-line"
onClick={() => {
if (onClose) {
onClose();
} else {
setForm(EMPTY_FORM);
setMsg(null);
}
}}
disabled={submitting}
>
取消
</button>
<button type="submit" disabled={submitting} className="btn btn-primary">
{submitting ? "提交中…" : "提交申请"}
</button>
</div>
{canManage ? (
<p className="meta text-[12px]">
管理员可前往{" "}
<Link href="/admin/friend-links" className="underline hover:text-[var(--accent)]">
友情链接管理
</Link>{" "}
审批
</p>
) : null}
</form>
);
}

View File

@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { fetchFriendLinks, fetchSiteState, type FriendLink } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { brandAliasPath } from "@/lib/brand";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { requestCanonical } from "@/lib/canonicalMeta";
import { resolveSiteOrigin } from "@/lib/siteUrl";
import LinksBoard from "./LinksBoard";
export async function generateMetadata(): Promise<Metadata> {
return {
title: "友情链接",
description: "本站友情链接收录与申请",
...(await requestCanonical("/links")),
};
}
export default async function FriendLinksPage() {
const cookie = authCookieHeader(await cookies());
let links: FriendLink[] = [];
let error = "";
try {
const res = await fetchFriendLinks(cookie || undefined);
links = res.links ?? [];
} catch (e) {
error = e instanceof Error ? e.message : "加载友情链接失败";
}
const [settings, siteState, me] = await Promise.all([
getPublicSettingsCached(),
fetchSiteState(),
getMeCached(cookie || undefined),
]);
const isLoggedIn = !!me.user;
const canManage = !!me.user && isAdminOrAbove(me.user.role);
// 站点 LOGO 用固定对外地址(换图不变链,对方收藏的地址永不失效),补全为绝对地址供申请人直接复制
const siteBase = siteState.site_url?.trim() || resolveSiteOrigin() || "";
const logoAlias = settings.logo_light_url
? brandAliasPath("logo_light")
: settings.favicon_url
? brandAliasPath("favicon")
: "";
const siteLogo = logoAlias && siteBase ? new URL(logoAlias, siteBase).toString() : "";
return (
<div className="mx-auto w-full max-w-[1200px] px-4 sm:px-6 py-8">
{/* 标题 + 申请须知 */}
<header className="text-center">
<h1
className="text-[26px] sm:text-[30px] font-extrabold tracking-tight inline-flex flex-col items-center"
style={{ color: "var(--ink)" }}
>
友情链接
<span
className="mt-1.5 h-[3px] w-10 rounded-full"
style={{ background: "var(--accent)" }}
aria-hidden
/>
</h1>
</header>
{/* 搜索 + 卡片墙 + 我的申请 + 申请弹窗 */}
<div className="mt-5">
{error ? (
<p
className="rounded-lg px-3 py-2 text-[13px] text-center"
style={{ background: "var(--panel-2)", color: "var(--ink)", border: "1px solid var(--line)" }}
>
{error}
</p>
) : (
<LinksBoard
links={links}
isLoggedIn={isLoggedIn}
canManage={canManage}
siteName={settings.site_name}
siteUrl={siteState.site_url}
siteLogo={siteLogo}
/>
)}
</div>
</div>
);
}

View File

@@ -106,24 +106,7 @@ export default async function SitePagePublic({ params }: PageProps) {
<>
<JsonLd data={jsonLd} />
<SiteDocShell
left={left}
right={right}
mobileExtras={
<>
{toc.length > 0 ? <PostToc items={toc} /> : null}
<SiteDocPageNav
pages={sidebarPages}
currentSlug={slug}
title="文档目录"
canManage={canManagePages}
/>
{toc.length === 0 && announcements.length > 0 ? (
<SiteDocAnnoNav items={announcements} />
) : null}
</>
}
>
<SiteDocShell left={left} right={right}>
<article className="panel p-6 sm:p-8">
<SiteDocMasthead
kicker="官方文件"
@@ -145,7 +128,8 @@ export default async function SitePagePublic({ params }: PageProps) {
/>
</article>
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
{/* 手机端 #main 无底部内边距,补 mb 与 footer 拉开距离;桌面端由 lg:pb 提供,归零 */}
<div className="mt-5 mb-6 lg:mb-0 flex items-center gap-2 text-[12.5px] meta">
<Megaphone size={14} style={{ color: "var(--accent)" }} />
<span>由站点管理团队维护</span>
</div>

View File

@@ -7,6 +7,8 @@ import {
fetchOverview,
fetchBoardSidebar,
fetchSidebar,
fetchFriendLinks,
type FriendLink,
type OverviewResponse,
type BoardSidebarResponse,
type SidebarPublicPayload,
@@ -109,13 +111,18 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const sidebarPromise: Promise<SidebarPublicPayload | null> = isSearch
? Promise.resolve(null)
: fetchSidebar(cookie).catch(() => null);
// 友情链接列表失败不阻塞页面
const friendLinksPromise: Promise<{ links: FriendLink[] } | null> = isSearch
? Promise.resolve(null)
: fetchFriendLinks(cookie).catch(() => null);
const [me, postsData, overview, boardSidebar, sidebarData] = await Promise.all([
const [me, postsData, overview, boardSidebar, sidebarData, friendLinksData] = await Promise.all([
mePromise,
postsPromise,
overviewPromise,
boardSidebarPromise,
sidebarPromise,
friendLinksPromise,
]);
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
const user = me.user;
@@ -142,6 +149,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
user,
boards,
checkin: (boardSidebar ?? overview)?.checkin ?? undefined,
// 友链列表独立于 overview:即使聚合接口失败也保留「申请」入口
friendLinks: friendLinksData?.links ?? [],
...(overview
? {
stats: overview.stats,
@@ -171,11 +180,6 @@ export default async function HomePage({ searchParams }: HomePageProps) {
}
: {}),
};
// 窄屏堆叠:右栏列表 + 左栏列表(板块目录已在顶部有横向板块条,跳过)
const mobileBlocks = [
...scopeLists.right,
...scopeLists.left.filter((r) => r.key !== "board_nav"),
];
const pageName = isSearch
? `搜索“${keyword}”`
@@ -240,7 +244,31 @@ export default async function HomePage({ searchParams }: HomePageProps) {
return (
<div className="j13-fluid-home">
<JsonLd data={listingLd} />
<WorkspaceNavMobile boards={boards} />
{/* 移动端:顶部横向板块条 = 左右抽屉的触发行——
☰ 打开左抽屉(桌面左栏:板块目录 + 工作台),▦ 打开右抽屉(桌面右栏面板) */}
<WorkspaceNavMobile
boards={boards}
left={
scopeLists.left.length > 0 ? (
<SidebarRenderer
blocks={scopeLists.left}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
) : undefined
}
right={
scopeLists.right.length > 0 ? (
<SidebarRenderer
blocks={scopeLists.right}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
) : undefined
}
/>
<div className={`flex items-start gap-6 xl:gap-7 flex-1 ${activeBoard ? "" : "lg:mt-0"}`}>
{/* 左栏:按配置渲染(缺省 = 板块目录) */}
@@ -326,17 +354,6 @@ export default async function HomePage({ searchParams }: HomePageProps) {
</aside>
) : null}
</div>
{/* 窄屏:面板下沉到帖子流之后(右栏列表 + 左栏列表,板块目录除外) */}
{mobileBlocks.length > 0 && (
<div className="j13-flat-rail mt-3 lg:hidden">
<SidebarRenderer
blocks={mobileBlocks}
widgets={sidebar.widgets}
props={sidebarProps}
/>
</div>
)}
</div>
);
}

View File

@@ -16,7 +16,6 @@ import {
fetchUserProfile,
fetchSidebar,
postDeleteTypeLabel,
type UserProfile,
type PostListItem,
type CommentsResponse,
type SidebarPublicPayload,
@@ -39,6 +38,7 @@ import { publicMarkdownForSeo } from "@/lib/hideBlocks";
import PostAttachments from "@/components/PostAttachments";
import PostEditedMark from "@/components/PostEditedMark";
import PostInteractPanels, { QuestionStatusCard } from "@/components/PostInteractPanels";
import MobileRailDrawers from "@/components/MobileRailDrawers";
import AcceptedAnswerCard from "@/components/AcceptedAnswerCard";
import { canModerateBoard } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons";
@@ -122,7 +122,7 @@ function RelatedPosts({
const titleId = `${uid}-title`;
return (
<section
className="j13-module shrink-0 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
className="j13-module shrink-0"
aria-labelledby={titleId}
>
<p className="j13-module-title mb-1.5" id={titleId}>
@@ -163,87 +163,9 @@ function RelatedPosts({
);
}
/* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */
function AuthorCardStacked({
profile,
allowComments,
boardNames,
boardColors,
urlStyle,
}: {
profile: UserProfile;
allowComments: boolean;
/** 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 */
boardNames?: Record<number, string>;
boardColors?: Record<number, number>;
urlStyle: UrlStyle;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
const { post_count, comment_count, points, streak } = profile.stats;
return (
<div className="panel p-4 sm:p-6 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
<p
className="text-[11.5px] font-medium tracking-[0.08em] uppercase mb-3.5"
style={{ color: "var(--ink-3)" }}
>
关于作者
</p>
<div className="flex items-start gap-4 flex-wrap">
<Link href={userPath(urlStyle, u.id)} className="shrink-0" aria-label={u.nickname}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={52} />
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={userPath(urlStyle, u.id)}
className="font-semibold text-[15px] hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{u.nickname}
</Link>
<LevelBadge level={u.level} />
{(u.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
{u.role !== "user" && (
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNames} boardColors={boardColors} />
)}
</div>
<p className="meta mt-1 flex items-center gap-1.5 flex-wrap">
<span>@{u.username}</span>
<span aria-hidden>·</span>
<span>发帖 {post_count}</span>
{allowComments ? (
<>
<span aria-hidden>·</span>
<span>评论 {comment_count}</span>
</>
) : null}
<span aria-hidden>·</span>
<span>积分 {points}</span>
{streak > 0 && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-0.5 font-medium" style={{ color: "var(--gold)" }}>
<Flame size={11} /> 连签 {streak} 天
</span>
</>
)}
</p>
{signature ? (
<p className="mt-2 text-[13px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
{signature}
</p>
) : null}
</div>
<Link href={userPath(urlStyle, u.id)} className="btn btn-line btn-sm shrink-0 self-center">
访问主页
</Link>
</div>
</div>
);
}
/* ============================================================
右栏模块(lg 起展示;移动端收进右抽屉)
============================================================ */
export default async function PostDetailPage({ params, searchParams }: PageProps) {
const { id } = await params;
@@ -374,6 +296,29 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
? "lg:grid-cols-[224px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)]"
: "lg:grid-cols-[minmax(0,1fr)_280px] xl:grid-cols-[minmax(0,1fr)_300px]";
// 移动端抽屉内容(lg 以下):左抽屉 = 左栏侧栏块,右抽屉 = 右栏侧栏块(含文章目录)+ 同板块热议;
// 桌面常驻 aside 与抽屉共用渲染函数,uid 区分同屏两份 DOM 的 aria-labelledby
const leftRailNode = hasLeftRail ? (
<SidebarRenderer blocks={sidebarLists.left} widgets={sidebar.widgets} props={sidebarProps} />
) : null;
const hasTocBlock = sidebarLists.right.some((r) => r.key === "post_toc");
const renderRightRail = (uid: string) => (
<>
{hasRightRail ? (
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} />
) : null}
{related.length > 0 ? (
<RelatedPosts
posts={related}
boardId={post.board_id}
uid={uid}
allowComments={allowComments}
urlStyle={urlStyle}
/>
) : null}
</>
);
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
const isPublished = post.status === "published";
const postType = normalizePostType(post.post_type);
@@ -487,6 +432,26 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
return (
<>
<JsonLd data={jsonLd} />
{/* 移动端:吸顶工具栏 + 左右抽屉(导航 / 目录),lg 起隐藏;两侧皆无内容时组件自身不渲染 */}
<MobileRailDrawers
center={
<>
<p className="truncate text-[13px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
{post.title}
</p>
<p className="meta mt-0.5 text-[10.5px] leading-tight">约 {readingMinutes} 分钟读完</p>
</>
}
left={leftRailNode ? { label: "导航", content: leftRailNode } : undefined}
right={
hasRightRail || related.length > 0
? {
label: hasTocBlock ? "目录" : "热议",
content: renderRightRail("related-drawer"),
}
: undefined
}
/>
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏
侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */}
<div
@@ -500,7 +465,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="帖子导航"
>
<SidebarRenderer blocks={sidebarLists.left} widgets={sidebar.widgets} props={sidebarProps} />
{leftRailNode}
</aside>
)}
@@ -697,18 +662,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</div>
</div>
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
{profile && (
<div className="lg:hidden mt-4">
<AuthorCardStacked
profile={profile}
allowComments={allowComments}
boardNames={boardNames}
boardColors={boardColors}
urlStyle={urlStyle}
/>
</div>
)}
{/* 窄屏作者卡已移除:作者信息随左栏配置收进移动端左抽屉 */}
{/* 关评:前台仅提示不支持评论,不展示历史楼层;非公开帖不提及评论 */}
{isPublished ? (
@@ -756,17 +710,6 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</p>
</section>
) : null}
{/* 窄屏相关讨论(lg 起由右栏承载) */}
<div className="lg:hidden mt-4">
<RelatedPosts
posts={related}
boardId={post.board_id}
uid="related-inline"
allowComments={allowComments}
urlStyle={urlStyle}
/>
</div>
</article>
</div>
@@ -776,8 +719,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="阅读辅助"
>
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} />
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} urlStyle={urlStyle} />
{renderRightRail("related")}
</aside>
)}
</div>

View File

@@ -4,9 +4,10 @@ import {
fetchPosts,
fetchAnnouncementsPage,
fetchPublishedSitePages,
fetchLibraryList,
} from "@/lib/api";
import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl";
import { announcementPath, postPath, pagePath, leaderboardPath } from "@/lib/urlStyle";
import { announcementPath, postPath, pagePath, leaderboardPath, libraryPath, libraryDocPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
@@ -33,6 +34,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "daily",
priority: 0.5,
},
{
url: url(libraryPath(style)),
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.5,
},
{
url: url("/about"),
lastModified: new Date(),
@@ -83,5 +90,19 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
/* 忽略 */
}
try {
const { docs } = await fetchLibraryList();
for (const d of docs) {
entries.push({
url: url(libraryDocPath(style, d.slug)),
lastModified: new Date(d.updated_at || d.created_at),
changeFrequency: "weekly",
priority: 0.4,
});
}
} catch {
/* 忽略 */
}
return entries;
}