feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
493
frontend/app/admin/friend-links/FriendLinksAdmin.tsx
Normal file
493
frontend/app/admin/friend-links/FriendLinksAdmin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/app/admin/friend-links/page.tsx
Normal file
36
frontend/app/admin/friend-links/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
431
frontend/app/admin/library/ImportMdDialog.tsx
Normal file
431
frontend/app/admin/library/ImportMdDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1031
frontend/app/admin/library/LibraryAdmin.tsx
Normal file
1031
frontend/app/admin/library/LibraryAdmin.tsx
Normal file
File diff suppressed because it is too large
Load Diff
97
frontend/app/admin/library/LibraryCoverPicker.tsx
Normal file
97
frontend/app/admin/library/LibraryCoverPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
505
frontend/app/admin/library/LibrarySectionsPanel.tsx
Normal file
505
frontend/app/admin/library/LibrarySectionsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/app/admin/library/page.tsx
Normal file
36
frontend/app/admin/library/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/app/admin/library/parseEpub.ts
Normal file
91
frontend/app/admin/library/parseEpub.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} /> 上传
|
||||
|
||||
322
frontend/app/admin/settings/HeaderNavPanel.tsx
Normal file
322
frontend/app/admin/settings/HeaderNavPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
frontend/app/admin/settings/MobileTabBarPanel.tsx
Normal file
174
frontend/app/admin/settings/MobileTabBarPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
261
frontend/app/library/[slug]/page.tsx
Normal file
261
frontend/app/library/[slug]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
360
frontend/app/library/[slug]/read/page.tsx
Normal file
360
frontend/app/library/[slug]/read/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
frontend/app/library/page.tsx
Normal file
178
frontend/app/library/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
506
frontend/app/links/LinksBoard.tsx
Normal file
506
frontend/app/links/LinksBoard.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
247
frontend/app/links/LinksClient.tsx
Normal file
247
frontend/app/links/LinksClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/app/links/page.tsx
Normal file
86
frontend/app/links/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
53
frontend/components/AdaptiveCoverSlot.tsx
Normal file
53
frontend/components/AdaptiveCoverSlot.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
/** 顶部横版槽位的最小门槛:自然宽度低于该值的横版图视为过小,不占顶部布局、归入左栏 */
|
||||
const MIN_TOP_WIDTH = 600;
|
||||
|
||||
/**
|
||||
* 封面自适应槽位:同一封面在两个位置各放一个槽位(左栏=竖版、顶部全宽=横版),
|
||||
* 组件用隐藏探测图读取自然宽高,只渲染与自身方向匹配的槽位。
|
||||
* 竖版槽默认显示(书封多为竖版),探测为横版时两槽自动换位,避免闪空。
|
||||
* 横版但过小(自然宽度 < MIN_TOP_WIDTH)的图同样只渲染在左栏槽位。
|
||||
*/
|
||||
export default function AdaptiveCoverSlot({
|
||||
src,
|
||||
orientation,
|
||||
children,
|
||||
}: {
|
||||
src: string;
|
||||
orientation: "portrait" | "landscape";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
const [match, setMatch] = useState(orientation === "portrait");
|
||||
|
||||
const measure = (img: HTMLImageElement) => {
|
||||
if (!img.naturalWidth || !img.naturalHeight) return;
|
||||
const landscape = img.naturalWidth > img.naturalHeight;
|
||||
// 仅“横版且足够大”才配得上顶部槽位;过小的横版图随竖版走左栏
|
||||
const topWorthy = landscape && img.naturalWidth >= MIN_TOP_WIDTH;
|
||||
setMatch(orientation === "landscape" ? topWorthy : !topWorthy);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 缓存图可能在 hydration 前就完成加载而错过 onLoad
|
||||
if (ref.current?.complete) measure(ref.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
ref={ref}
|
||||
src={src}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="hidden"
|
||||
onLoad={(e) => measure(e.currentTarget)}
|
||||
/>
|
||||
{match ? children : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export default function ConfirmDialog({
|
||||
cancelLabel = "取消",
|
||||
danger = false,
|
||||
busy = false,
|
||||
zIndexClass = "z-[1100]",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
@@ -24,6 +25,8 @@ export default function ConfirmDialog({
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
busy?: boolean;
|
||||
/** 叠层高度;默认 1100,被更高弹窗遮挡时上调(如编辑器主弹窗 1200 时传 1300) */
|
||||
zIndexClass?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
@@ -33,7 +36,7 @@ export default function ConfirmDialog({
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
busy={busy}
|
||||
zIndexClass="z-[1100]"
|
||||
zIndexClass={zIndexClass}
|
||||
maxWidthClass="max-w-sm"
|
||||
panelClassName="p-5"
|
||||
footer={
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
Bell,
|
||||
Home,
|
||||
Trophy,
|
||||
BookOpen,
|
||||
User as UserIcon,
|
||||
ChevronDown,
|
||||
ShieldCheck,
|
||||
MessageSquare,
|
||||
X,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
@@ -34,8 +36,13 @@ import LevelBadge from "./LevelBadge";
|
||||
import BadgeChip from "./BadgeChip";
|
||||
import BrandLockup from "./BrandLockup";
|
||||
import { useAllowMessages } from "./MessagesPolicyProvider";
|
||||
import { useUrlStyle } from "./SiteBrandProvider";
|
||||
import { leaderboardPath } from "@/lib/urlStyle";
|
||||
import { useSiteBrand, useUrlStyle } from "./SiteBrandProvider";
|
||||
import { leaderboardPath, libraryPath } from "@/lib/urlStyle";
|
||||
import {
|
||||
HEADER_NAV_BUILTIN_LABELS,
|
||||
isHeaderNavExternal,
|
||||
type HeaderNavBuiltinKey,
|
||||
} from "@/lib/headerNav";
|
||||
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
|
||||
|
||||
// 搜索关键词长度上限(与后端 handler maxSearchKeywordRunes 一致);
|
||||
@@ -72,6 +79,33 @@ export default function Header({
|
||||
const pathname = usePathname();
|
||||
const urlStyle = useUrlStyle();
|
||||
const lbHref = leaderboardPath(urlStyle);
|
||||
const libHref = libraryPath(urlStyle);
|
||||
// 顶部主导航由后台「基本信息 → 顶部导航」配置(顺序/显隐/自定义链接),
|
||||
// 配置为空时整个 nav 不渲染(含左侧分隔线);移动端导航走底部公共菜单
|
||||
const headerNav = useSiteBrand().header_nav;
|
||||
const builtinNavIcon: Record<HeaderNavBuiltinKey, React.ReactNode> = {
|
||||
home: <Home size={15} />,
|
||||
leaderboard: <Trophy size={15} />,
|
||||
library: <BookOpen size={15} />,
|
||||
};
|
||||
const builtinNavHref: Record<HeaderNavBuiltinKey, string> = {
|
||||
home: "/",
|
||||
leaderboard: lbHref,
|
||||
library: libHref,
|
||||
};
|
||||
const isBuiltinNavActive = (key: HeaderNavBuiltinKey): boolean => {
|
||||
if (!pathname) return false;
|
||||
if (key === "home") return pathname === "/";
|
||||
if (key === "leaderboard") return pathname === lbHref || pathname.startsWith(`${lbHref}/`);
|
||||
// 书库详情/阅读页标准路径与伪静态(/library-*.html)统一按 /library 前缀高亮
|
||||
return pathname.startsWith("/library");
|
||||
};
|
||||
// 自定义站内菜单高亮:精确或子路径;"/" 仅精确,避免高亮全站
|
||||
const isCustomNavActive = (url: string): boolean => {
|
||||
if (!pathname || !url.startsWith("/") || url.startsWith("//")) return false;
|
||||
if (url === "/") return pathname === "/";
|
||||
return pathname === url || pathname.startsWith(`${url}/`);
|
||||
};
|
||||
const allowMessages = useAllowMessages();
|
||||
const hideComposeNav =
|
||||
pathname === "/compose" ||
|
||||
@@ -244,18 +278,35 @@ export default function Header({
|
||||
logoDark={logoDark}
|
||||
/>
|
||||
</Link>
|
||||
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
|
||||
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>
|
||||
首页
|
||||
</NavPill>
|
||||
<NavPill
|
||||
href={lbHref}
|
||||
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
|
||||
icon={<Trophy size={15} />}
|
||||
>
|
||||
排行榜
|
||||
</NavPill>
|
||||
</nav>
|
||||
{headerNav.length > 0 && (
|
||||
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
|
||||
{headerNav.map((item, idx) =>
|
||||
item.type === "builtin" ? (
|
||||
<NavPill
|
||||
key={`builtin-${item.key}`}
|
||||
href={builtinNavHref[item.key]}
|
||||
active={isBuiltinNavActive(item.key)}
|
||||
icon={builtinNavIcon[item.key]}
|
||||
>
|
||||
{HEADER_NAV_BUILTIN_LABELS[item.key]}
|
||||
</NavPill>
|
||||
) : (
|
||||
<NavPill
|
||||
key={`custom-${idx}`}
|
||||
href={item.url}
|
||||
external={isHeaderNavExternal(item.url)}
|
||||
newTab={item.new_tab}
|
||||
active={!isHeaderNavExternal(item.url) && isCustomNavActive(item.url)}
|
||||
>
|
||||
{item.label}
|
||||
{isHeaderNavExternal(item.url) && item.new_tab ? (
|
||||
<ExternalLink size={12} aria-hidden style={{ color: "var(--ink-3)" }} />
|
||||
) : null}
|
||||
</NavPill>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
|
||||
@@ -415,23 +466,36 @@ function NavPill({
|
||||
href,
|
||||
active,
|
||||
icon,
|
||||
external,
|
||||
newTab,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
external?: boolean;
|
||||
newTab?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cls =
|
||||
"inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors";
|
||||
const style =
|
||||
active
|
||||
? { background: "transparent", color: "var(--accent)" }
|
||||
: { color: "var(--ink)" };
|
||||
const target = newTab ? "_blank" : undefined;
|
||||
const rel = newTab ? "noopener noreferrer" : undefined;
|
||||
// 自定义外链渲染原生 <a>;站内链接(含勾选新标签的)走 Next Link 保留软导航
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target={target} rel={rel} className={cls} style={style}>
|
||||
{icon}
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors"
|
||||
style={
|
||||
active
|
||||
? { background: "transparent", color: "var(--accent)" }
|
||||
: { color: "var(--ink)" }
|
||||
}
|
||||
>
|
||||
<Link href={href} target={target} rel={rel} className={cls} style={style}>
|
||||
{icon}
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
48
frontend/components/LightboxImage.tsx
Normal file
48
frontend/components/LightboxImage.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import ImageLightbox from "@/components/markdown/ImageLightbox";
|
||||
|
||||
/**
|
||||
* 静态图片图箱入口(区别于 markdown 管线的 MdImage):
|
||||
* 封面等非 markdown 图片点击进全屏图箱(单图,无多图切换)。
|
||||
* 在 Link 内使用时 preventDefault + stopPropagation 阻断外层导航——
|
||||
* 点图片本身是看图,点卡片其余区域仍走原链接。
|
||||
*/
|
||||
export default function LightboxImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`cursor-zoom-in${className ? ` ${className}` : ""}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
{open && (
|
||||
<ImageLightbox
|
||||
items={[{ src, alt }]}
|
||||
index={0}
|
||||
onClose={() => setOpen(false)}
|
||||
onIndexChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
|
||||
import type { HideKind } from "@/lib/hideBlocks";
|
||||
import MdHideBlock from "@/components/markdown/MdHideBlock";
|
||||
import MdTimeline from "@/components/markdown/MdTimeline";
|
||||
import MdEntries from "@/components/markdown/MdEntries";
|
||||
|
||||
type MarkdownBodyProps = {
|
||||
content: string;
|
||||
@@ -16,10 +17,14 @@ type MarkdownBodyProps = {
|
||||
/** 登录跳转(公告等无 postId 时) */
|
||||
loginHref?: string;
|
||||
loggedIn?: boolean;
|
||||
/** 书级「全章节条目卡模式」:章节内容免写 [entries] 壳 */
|
||||
entriesAuto?: boolean;
|
||||
/** 按分段索引的条目标题锚点 id(与章内目录共享;阅读页传入,其他场景缺省) */
|
||||
entriesTitleIds?: (string[] | undefined)[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 [hide] / [timeline]。
|
||||
* 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 [hide] / [timeline] / [entries]。
|
||||
*/
|
||||
export default async function MarkdownBody({
|
||||
content,
|
||||
@@ -28,8 +33,12 @@ export default async function MarkdownBody({
|
||||
postId,
|
||||
loginHref,
|
||||
loggedIn = false,
|
||||
entriesAuto = false,
|
||||
entriesTitleIds,
|
||||
}: MarkdownBodyProps) {
|
||||
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""));
|
||||
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""), {
|
||||
entriesAuto,
|
||||
});
|
||||
const nodes = [];
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const seg = segs[i];
|
||||
@@ -60,6 +69,12 @@ export default async function MarkdownBody({
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (seg.type === "entries") {
|
||||
nodes.push(
|
||||
<MdEntries key={`entries-${i}`} items={seg.items} titleIds={entriesTitleIds?.[i]} />
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const kind = seg.kind as HideKind;
|
||||
if (seg.locked) {
|
||||
nodes.push(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
|
||||
import type { HideKind } from "@/lib/hideBlocks";
|
||||
import MdHideBlock from "@/components/markdown/MdHideBlock";
|
||||
import MdTimeline from "@/components/markdown/MdTimeline";
|
||||
import { MdEntriesView } from "@/components/markdown/MdEntries";
|
||||
|
||||
type MarkdownBodyClientProps = {
|
||||
content: string;
|
||||
@@ -16,10 +17,14 @@ type MarkdownBodyClientProps = {
|
||||
postId?: number;
|
||||
loginHref?: string;
|
||||
loggedIn?: boolean;
|
||||
/** 书级「全章节条目卡模式」:章节内容免写 [entries] 壳 */
|
||||
entriesAuto?: boolean;
|
||||
/** 按分段索引的条目标题锚点 id(与章内目录共享;阅读页传入,其他场景缺省) */
|
||||
entriesTitleIds?: (string[] | undefined)[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块 / 时间线预览。
|
||||
* 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块 / 时间线 / 条目卡预览。
|
||||
*/
|
||||
export default function MarkdownBodyClient({
|
||||
content,
|
||||
@@ -28,8 +33,12 @@ export default function MarkdownBodyClient({
|
||||
postId,
|
||||
loginHref,
|
||||
loggedIn = false,
|
||||
entriesAuto = false,
|
||||
entriesTitleIds,
|
||||
}: MarkdownBodyClientProps) {
|
||||
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""));
|
||||
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""), {
|
||||
entriesAuto,
|
||||
});
|
||||
const mdComponents = mergeMarkdownComponents(components);
|
||||
// 时间线/隐藏块正文不套外层 heading 覆盖,避免预览与正文目录 id 错位
|
||||
const nestedMd = mergeMarkdownComponents();
|
||||
@@ -60,6 +69,20 @@ export default function MarkdownBodyClient({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (seg.type === "entries") {
|
||||
return (
|
||||
<MdEntriesView
|
||||
key={`entries-${i}`}
|
||||
items={seg.items}
|
||||
titleIds={entriesTitleIds?.[i]}
|
||||
renderMd={(t) => (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
||||
{expandDyEmojiShortcodes(t)}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const kind = seg.kind as HideKind;
|
||||
if (seg.locked) {
|
||||
return (
|
||||
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
History,
|
||||
Ellipsis,
|
||||
Smile,
|
||||
LayoutList,
|
||||
} from "lucide-react";
|
||||
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||
import ImageInsertModal from "@/components/ImageInsertModal";
|
||||
import TimelineEditor from "@/components/TimelineEditor";
|
||||
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
|
||||
import EmojiPicker from "@/components/EmojiPicker";
|
||||
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
|
||||
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
|
||||
@@ -79,6 +81,8 @@ type MarkdownEditorProps = {
|
||||
/** 提交失败等原因,显示在编辑器内 */
|
||||
error?: string;
|
||||
onUploadingChange?: (uploading: boolean) => void;
|
||||
/** 书级「全章节条目卡模式」:插入免壳模板、预览按隐式条目渲染 */
|
||||
entriesAuto?: boolean;
|
||||
};
|
||||
|
||||
type EditorMode = "write" | "preview";
|
||||
@@ -159,6 +163,7 @@ export default function MarkdownEditor({
|
||||
maxChars,
|
||||
error,
|
||||
onUploadingChange,
|
||||
entriesAuto = false,
|
||||
}: MarkdownEditorProps) {
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const urlStyle = useUrlStyle();
|
||||
@@ -212,10 +217,9 @@ export default function MarkdownEditor({
|
||||
}, [uploading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isComment) return;
|
||||
const el = toolbarRef.current;
|
||||
if (!el) return;
|
||||
const apply = () => setToolNarrow(el.clientWidth < 520);
|
||||
const apply = () => setToolNarrow(el.clientWidth < (isComment ? 520 : 640));
|
||||
apply();
|
||||
const ro = new ResizeObserver(apply);
|
||||
ro.observe(el);
|
||||
@@ -742,6 +746,180 @@ export default function MarkdownEditor({
|
||||
l.toLowerCase().includes(codeQuery.trim().toLowerCase())
|
||||
);
|
||||
|
||||
// 代码块语言弹层:常规时挂在行内按钮下,窄屏收进「更多」后挂在其锚点下(居中防溢出)
|
||||
const codePopover =
|
||||
popover === "code" ? (
|
||||
<div
|
||||
className={`j13-md-popover j13-md-popover-wide${toolNarrow ? " j13-md-popover-center" : ""}`}
|
||||
role="dialog"
|
||||
aria-label="选择语言"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
className="j13-md-popover-input"
|
||||
placeholder="搜索语言…"
|
||||
value={codeQuery}
|
||||
onChange={(e) => setCodeQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && filteredLangs[0]) {
|
||||
e.preventDefault();
|
||||
insertCodeBlock(filteredLangs[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="j13-md-popover-list">
|
||||
{filteredLangs.map((lang) => (
|
||||
<button key={lang} type="button" onClick={() => insertCodeBlock(lang)}>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
{filteredLangs.length === 0 && (
|
||||
<p className="meta px-2 py-3 text-center">无匹配语言</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// 隐藏内容弹层:同上,窄屏时改挂在「更多」锚点下
|
||||
const hidePopover =
|
||||
allowHide && popover === "hide" ? (
|
||||
<div
|
||||
className={`j13-md-popover j13-md-popover-form${toolNarrow ? " j13-md-popover-center" : ""}`}
|
||||
role="dialog"
|
||||
aria-label="插入隐藏内容"
|
||||
>
|
||||
{hideStep === "menu" ? (
|
||||
<>
|
||||
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
|
||||
登录可见
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-menu-item"
|
||||
disabled={!allowReplyHide}
|
||||
data-tip={allowReplyHide ? undefined : "当前内容不可评论,无法使用回复可见"}
|
||||
aria-disabled={!allowReplyHide}
|
||||
onClick={() => {
|
||||
if (!allowReplyHide) return;
|
||||
insertHide("reply");
|
||||
}}
|
||||
>
|
||||
回复可见
|
||||
{!allowReplyHide ? (
|
||||
<span className="j13-md-menu-item-hint">不可用</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-menu-item"
|
||||
onClick={() => setHideStep("points")}
|
||||
>
|
||||
积分可见…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-menu-item"
|
||||
onClick={() => setHideStep("password")}
|
||||
>
|
||||
密码可见…
|
||||
</button>
|
||||
</>
|
||||
) : hideStep === "points" ? (
|
||||
<>
|
||||
<label className="j13-md-field">
|
||||
<span>所需积分</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
value={hidePoints}
|
||||
onChange={(e) => setHidePoints(Number(e.target.value) || 1)}
|
||||
className="j13-md-popover-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (hidePoints >= 1 && hidePoints <= 100000) {
|
||||
insertHide("points", hidePoints);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="j13-md-popover-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setHideStep("menu")}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => {
|
||||
if (hidePoints < 1 || hidePoints > 100000) {
|
||||
toast("积分须在 1–100000", "error");
|
||||
return;
|
||||
}
|
||||
insertHide("points", hidePoints);
|
||||
}}
|
||||
>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="j13-md-field">
|
||||
<span>查看密码(不含空格,1–64 字)</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={hidePassword}
|
||||
maxLength={64}
|
||||
placeholder="例如:secret"
|
||||
onChange={(e) => setHidePassword(e.target.value)}
|
||||
className="j13-md-popover-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const p = hidePassword.trim();
|
||||
if (p && !/\s/.test(p)) {
|
||||
insertHide("password", undefined, p);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="j13-md-popover-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setHideStep("menu")}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => {
|
||||
const p = hidePassword.trim();
|
||||
if (!p || /\s/.test(p) || p.length > 64) {
|
||||
toast("密码须为 1–64 字符且不含空格", "error");
|
||||
return;
|
||||
}
|
||||
insertHide("password", undefined, p);
|
||||
}}
|
||||
>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const ToolBtn = ({
|
||||
label,
|
||||
icon,
|
||||
@@ -774,7 +952,11 @@ export default function MarkdownEditor({
|
||||
const Sep = () => <span className="j13-md-sep" aria-hidden />;
|
||||
|
||||
return (
|
||||
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}${isComment ? " is-comment" : ""}`}>
|
||||
<div
|
||||
className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}${
|
||||
isComment ? " is-comment" : ""
|
||||
}${toolNarrow ? " is-narrow" : ""}`}
|
||||
>
|
||||
<div className="j13-md-toolbar" ref={toolbarRef}>
|
||||
<div className="j13-md-tool-groups flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
|
||||
<ToolBtn
|
||||
@@ -782,21 +964,21 @@ export default function MarkdownEditor({
|
||||
icon={<Bold size={15} />}
|
||||
onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })}
|
||||
/>
|
||||
{!isComment && (
|
||||
{!isComment && !toolNarrow && (
|
||||
<ToolBtn
|
||||
label="斜体 Ctrl+I"
|
||||
icon={<Italic size={15} />}
|
||||
onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
|
||||
/>
|
||||
)}
|
||||
{!isComment && (
|
||||
{!isComment && !toolNarrow && (
|
||||
<ToolBtn
|
||||
label="删除线"
|
||||
icon={<Strikethrough size={15} />}
|
||||
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
|
||||
/>
|
||||
)}
|
||||
{!isComment && <Sep />}
|
||||
{!isComment && !toolNarrow && <Sep />}
|
||||
{!isComment && (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
@@ -838,8 +1020,8 @@ export default function MarkdownEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!isComment || !toolNarrow) && <Sep />}
|
||||
{(!isComment || !toolNarrow) && (
|
||||
{!toolNarrow && <Sep />}
|
||||
{!toolNarrow && (
|
||||
<ToolBtn
|
||||
label="引用"
|
||||
icon={<Quote size={15} />}
|
||||
@@ -848,7 +1030,7 @@ export default function MarkdownEditor({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(!isComment || !toolNarrow) && (
|
||||
{!toolNarrow && (
|
||||
<ToolBtn
|
||||
label="无序列表"
|
||||
icon={<List size={15} />}
|
||||
@@ -857,15 +1039,15 @@ export default function MarkdownEditor({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isComment && (
|
||||
{!isComment && !toolNarrow && (
|
||||
<ToolBtn
|
||||
label="有序列表"
|
||||
icon={<ListOrdered size={15} />}
|
||||
onClick={insertOrderedList}
|
||||
/>
|
||||
)}
|
||||
{!isComment && <ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />}
|
||||
{!isComment && (
|
||||
{!isComment && !toolNarrow && <ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />}
|
||||
{!isComment && !toolNarrow && (
|
||||
<ToolBtn
|
||||
label="分割线"
|
||||
icon={<Minus size={15} />}
|
||||
@@ -878,7 +1060,7 @@ export default function MarkdownEditor({
|
||||
icon={<Code size={15} />}
|
||||
onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })}
|
||||
/>
|
||||
{(!isComment || !toolNarrow) && (
|
||||
{!toolNarrow && (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
label="代码块 Ctrl+Shift+C"
|
||||
@@ -886,37 +1068,7 @@ export default function MarkdownEditor({
|
||||
active={popover === "code"}
|
||||
onClick={() => openPopover("code")}
|
||||
/>
|
||||
{popover === "code" && (
|
||||
<div className="j13-md-popover j13-md-popover-wide" role="dialog" aria-label="选择语言">
|
||||
<input
|
||||
autoFocus
|
||||
className="j13-md-popover-input"
|
||||
placeholder="搜索语言…"
|
||||
value={codeQuery}
|
||||
onChange={(e) => setCodeQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && filteredLangs[0]) {
|
||||
e.preventDefault();
|
||||
insertCodeBlock(filteredLangs[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="j13-md-popover-list">
|
||||
{filteredLangs.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => insertCodeBlock(lang)}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
{filteredLangs.length === 0 && (
|
||||
<p className="meta px-2 py-3 text-center">无匹配语言</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{codePopover}
|
||||
</div>
|
||||
)}
|
||||
<Sep />
|
||||
@@ -1008,6 +1160,7 @@ export default function MarkdownEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(isComment || !toolNarrow) && (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
label="表情"
|
||||
@@ -1021,6 +1174,7 @@ export default function MarkdownEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
label={uploading ? "上传中…" : "图片"}
|
||||
@@ -1030,12 +1184,12 @@ export default function MarkdownEditor({
|
||||
onClick={openImageModal}
|
||||
/>
|
||||
</div>
|
||||
{isComment && (
|
||||
{(isComment || toolNarrow) && (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<button
|
||||
ref={moreBtnRef}
|
||||
type="button"
|
||||
className={`j13-md-tool${popover === "more" || (toolNarrow && popover === "code") ? " is-active" : ""}`}
|
||||
className={`j13-md-tool${popover === "more" || (toolNarrow && (popover === "code" || popover === "emoji" || popover === "hide")) ? " is-active" : ""}`}
|
||||
data-tip="更多工具"
|
||||
aria-label="更多工具"
|
||||
aria-haspopup="menu"
|
||||
@@ -1060,55 +1214,49 @@ export default function MarkdownEditor({
|
||||
)}
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "*", after: "*", placeholder: "斜体" }); setPopover(null); }}>斜体</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "~~", after: "~~", placeholder: "删除线" }); setPopover(null); }}>删除线</button>
|
||||
{!isComment && (
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("emoji")}>表情</button>
|
||||
)}
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "## ", after: "", placeholder: "二级标题" }); setPopover(null); }}>二级标题</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "### ", after: "", placeholder: "三级标题" }); setPopover(null); }}>三级标题</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertOrderedList(); setPopover(null); }}>有序列表</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertTable(); setPopover(null); }}>表格</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor("---"); setPopover(null); }}>分隔线</button>
|
||||
{!isComment && allowHide && (
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("hide")}>隐藏内容</button>
|
||||
)}
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE); setPopover(null); }}>条目卡</button>
|
||||
{toolNarrow && (
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{toolNarrow && popover === "code" && (
|
||||
<div className="j13-md-popover j13-md-popover-wide" role="dialog" aria-label="选择语言">
|
||||
<input
|
||||
autoFocus
|
||||
className="j13-md-popover-input"
|
||||
placeholder="搜索语言…"
|
||||
value={codeQuery}
|
||||
onChange={(e) => setCodeQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && filteredLangs[0]) {
|
||||
e.preventDefault();
|
||||
insertCodeBlock(filteredLangs[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="j13-md-popover-list">
|
||||
{filteredLangs.map((lang) => (
|
||||
<button key={lang} type="button" onClick={() => insertCodeBlock(lang)}>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
{filteredLangs.length === 0 && (
|
||||
<p className="meta px-2 py-3 text-center">无匹配语言</p>
|
||||
)}
|
||||
</div>
|
||||
{toolNarrow && codePopover}
|
||||
{toolNarrow && popover === "emoji" && !isComment && (
|
||||
<div className="j13-md-popover j13-md-emoji-popover j13-md-popover-center" role="dialog" aria-label="插入表情">
|
||||
<EmojiPicker onPick={pickEmoji} />
|
||||
</div>
|
||||
)}
|
||||
{toolNarrow && hidePopover}
|
||||
</div>
|
||||
)}
|
||||
{!isComment && <Sep />}
|
||||
{!isComment && (
|
||||
{!isComment && !toolNarrow && <Sep />}
|
||||
{!isComment && !toolNarrow && (
|
||||
<ToolBtn
|
||||
label="时间线"
|
||||
icon={<History size={15} />}
|
||||
onClick={openTimelineEditor}
|
||||
/>
|
||||
)}
|
||||
{allowHide ? (
|
||||
{!isComment && !toolNarrow && (
|
||||
<ToolBtn
|
||||
label="条目卡"
|
||||
icon={<LayoutList size={15} />}
|
||||
onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)}
|
||||
/>
|
||||
)}
|
||||
{allowHide && !toolNarrow ? (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
label="隐藏内容"
|
||||
@@ -1116,139 +1264,7 @@ export default function MarkdownEditor({
|
||||
active={popover === "hide"}
|
||||
onClick={() => openPopover("hide")}
|
||||
/>
|
||||
{popover === "hide" && (
|
||||
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入隐藏内容">
|
||||
{hideStep === "menu" ? (
|
||||
<>
|
||||
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
|
||||
登录可见
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-menu-item"
|
||||
disabled={!allowReplyHide}
|
||||
data-tip={allowReplyHide ? undefined : "当前内容不可评论,无法使用回复可见"}
|
||||
aria-disabled={!allowReplyHide}
|
||||
onClick={() => {
|
||||
if (!allowReplyHide) return;
|
||||
insertHide("reply");
|
||||
}}
|
||||
>
|
||||
回复可见
|
||||
{!allowReplyHide ? (
|
||||
<span className="j13-md-menu-item-hint">不可用</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-menu-item"
|
||||
onClick={() => setHideStep("points")}
|
||||
>
|
||||
积分可见…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-menu-item"
|
||||
onClick={() => setHideStep("password")}
|
||||
>
|
||||
密码可见…
|
||||
</button>
|
||||
</>
|
||||
) : hideStep === "points" ? (
|
||||
<>
|
||||
<label className="j13-md-field">
|
||||
<span>所需积分</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
value={hidePoints}
|
||||
onChange={(e) => setHidePoints(Number(e.target.value) || 1)}
|
||||
className="j13-md-popover-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (hidePoints >= 1 && hidePoints <= 100000) {
|
||||
insertHide("points", hidePoints);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="j13-md-popover-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setHideStep("menu")}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => {
|
||||
if (hidePoints < 1 || hidePoints > 100000) {
|
||||
toast("积分须在 1–100000", "error");
|
||||
return;
|
||||
}
|
||||
insertHide("points", hidePoints);
|
||||
}}
|
||||
>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="j13-md-field">
|
||||
<span>查看密码(不含空格,1–64 字)</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={hidePassword}
|
||||
maxLength={64}
|
||||
placeholder="例如:secret"
|
||||
onChange={(e) => setHidePassword(e.target.value)}
|
||||
className="j13-md-popover-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const p = hidePassword.trim();
|
||||
if (p && !/\s/.test(p)) {
|
||||
insertHide("password", undefined, p);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="j13-md-popover-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setHideStep("menu")}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => {
|
||||
const p = hidePassword.trim();
|
||||
if (!p || /\s/.test(p) || p.length > 64) {
|
||||
toast("密码须为 1–64 字符且不含空格", "error");
|
||||
return;
|
||||
}
|
||||
insertHide("password", undefined, p);
|
||||
}}
|
||||
>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hidePopover}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1281,7 +1297,7 @@ export default function MarkdownEditor({
|
||||
<Eye size={12} /> 预览
|
||||
</button>
|
||||
</div>
|
||||
{(!isComment || !toolNarrow) && (
|
||||
{!toolNarrow && (
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
@@ -1354,7 +1370,7 @@ export default function MarkdownEditor({
|
||||
预览
|
||||
</div>
|
||||
{deferredValue.trim() ? (
|
||||
<MarkdownBodyClient content={deferredValue} />
|
||||
<MarkdownBodyClient content={deferredValue} entriesAuto={entriesAuto} />
|
||||
) : (
|
||||
<p className="meta text-center py-16">暂无内容可预览</p>
|
||||
)}
|
||||
|
||||
206
frontend/components/MobileRailDrawers.tsx
Normal file
206
frontend/components/MobileRailDrawers.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Hash, ListTree, X } from "lucide-react";
|
||||
|
||||
type DrawerSide = "left" | "right";
|
||||
|
||||
export interface RailDrawerSide {
|
||||
/** 触发按钮与抽屉标题 */
|
||||
label: string;
|
||||
/** 抽屉内容(服务端渲染节点) */
|
||||
content: ReactNode;
|
||||
/** 按钮图标(缺省:左 ListTree / 右 Hash) */
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 抽屉开合副作用:ESC 关闭 + 锁定背景滚动。
|
||||
* onClose 经 ref 引用,避免回调身份变化导致反复装卸监听与滚动锁泄漏。
|
||||
*/
|
||||
export function useDrawerChrome(open: boolean, onClose: () => void) {
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeRef.current();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单侧抽屉面板(lg 以下生效):遮罩 + 侧滑面板。
|
||||
* - 关闭时保留 DOM 以获得滑出过渡,invisible 延迟切换保证动画完整;
|
||||
* - 抽屉内点击链接(锚点 / 跳转)后自动收起。
|
||||
*/
|
||||
export function RailDrawerPanel({
|
||||
side,
|
||||
title,
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
side: DrawerSide;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="lg:hidden"
|
||||
style={{ visibility: open ? "visible" : "hidden", transition: "visibility .2s" }}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div
|
||||
className={`fixed inset-0 z-[1090] bg-black/40 transition-opacity duration-200 ${
|
||||
open ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={`fixed bottom-0 z-[1100] flex w-[min(82vw,320px)] flex-col ${
|
||||
side === "left" ? "left-0" : "right-0"
|
||||
} transition-transform duration-200 ease-out ${
|
||||
open ? "translate-x-0" : side === "left" ? "-translate-x-full" : "translate-x-full"
|
||||
}`}
|
||||
style={{
|
||||
top: 0,
|
||||
background: "var(--panel)",
|
||||
boxShadow: "0 0 40px rgba(0,0,0,0.18)",
|
||||
borderLeft: side === "right" ? "1px solid var(--line)" : undefined,
|
||||
borderRight: side === "left" ? "1px solid var(--line)" : undefined,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a")) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 px-4 py-3"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="text-[13px] font-bold" style={{ color: "var(--ink)" }}>
|
||||
{title}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto rounded-lg p-1.5 transition-colors hover:bg-[var(--panel-2)]"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={16} style={{ color: "var(--ink-2)" }} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="j13-rail-scroll flex flex-1 flex-col gap-3 overflow-y-auto p-3">
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用移动端侧栏抽屉(lg 以下生效,桌面仍用常驻侧栏):
|
||||
* - 吸顶工具栏贴在全局 Header 下方,左右各一个抽屉触发按钮,中间可放页面上文;
|
||||
* - 左右两侧内容均可选,两侧皆空时整体不渲染;
|
||||
* - 渐进增强:ESC 关闭、背景滚动锁定、点链接自动收起。
|
||||
*/
|
||||
export default function MobileRailDrawers({
|
||||
left,
|
||||
right,
|
||||
center,
|
||||
}: {
|
||||
/** 左抽屉(对应桌面左栏) */
|
||||
left?: RailDrawerSide;
|
||||
/** 右抽屉(对应桌面右栏) */
|
||||
right?: RailDrawerSide;
|
||||
/** 工具栏中间内容(如当前文章标题),可省略 */
|
||||
center?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState<DrawerSide | null>(null);
|
||||
useDrawerChrome(open !== null, () => setOpen(null));
|
||||
|
||||
if (!left && !right) return null;
|
||||
|
||||
const btn =
|
||||
"relative inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12.5px] font-medium shrink-0 transition-colors hover:bg-[var(--panel-2)] active:scale-[0.97] " +
|
||||
// 触控热区上下外扩 8px(视觉不变),移动端更易点中
|
||||
"before:absolute before:inset-x-0 before:-inset-y-2 before:content-['']";
|
||||
|
||||
const sides: { side: DrawerSide; cfg?: RailDrawerSide; fallbackIcon: ReactNode }[] = [
|
||||
{ side: "left", cfg: left, fallbackIcon: <ListTree size={15} style={{ color: "var(--accent)" }} aria-hidden /> },
|
||||
{ side: "right", cfg: right, fallbackIcon: <Hash size={15} style={{ color: "var(--accent)" }} aria-hidden /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 吸顶工具栏:与书籍阅读页一致的三段布局——左钮贴左、中间文字居中、右钮贴右;
|
||||
有中间文字而缺某侧按钮时以等宽占位保持文字不偏心 */}
|
||||
<header
|
||||
className="lg:hidden sticky z-30 mb-3 flex items-center gap-1 rounded-xl px-1 py-1"
|
||||
style={{
|
||||
top: "var(--header-h)",
|
||||
background: "color-mix(in srgb, var(--panel) 88%, transparent)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
border: "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
{left ? (
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
onClick={() => setOpen("left")}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{left.icon ?? sides[0].fallbackIcon}
|
||||
{left.label}
|
||||
</button>
|
||||
) : center ? (
|
||||
<span className="w-[52px] shrink-0" aria-hidden />
|
||||
) : null}
|
||||
|
||||
{center ? <div className="min-w-0 flex-1 px-1 text-center">{center}</div> : null}
|
||||
|
||||
{right ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${btn} ml-auto`}
|
||||
onClick={() => setOpen("right")}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{right.icon ?? sides[1].fallbackIcon}
|
||||
{right.label}
|
||||
</button>
|
||||
) : center ? (
|
||||
<span className="w-[52px] shrink-0" aria-hidden />
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{sides.map(({ side, cfg }) =>
|
||||
cfg ? (
|
||||
<RailDrawerPanel
|
||||
key={side}
|
||||
side={side}
|
||||
title={cfg.label}
|
||||
open={open === side}
|
||||
onClose={() => setOpen(null)}
|
||||
>
|
||||
{cfg.content}
|
||||
</RailDrawerPanel>
|
||||
) : null
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,26 +2,28 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import {
|
||||
Home,
|
||||
Trophy,
|
||||
BookOpen,
|
||||
PenSquare,
|
||||
MessageSquare,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import { parseEntityPath, userPath, leaderboardPath } from "@/lib/urlStyle";
|
||||
import { userPath, libraryPath } from "@/lib/urlStyle";
|
||||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||||
import type { RtChatUnreadData } from "@/lib/realtime";
|
||||
import type { MobileTabKey } from "@/lib/mobileTabs";
|
||||
|
||||
/**
|
||||
* 移动端全局底部公共菜单(lg 以下显示):
|
||||
* 首页 / 排行榜 / 发帖 / 消息(未登录不显示)/ 我的。
|
||||
* 聊天页、发帖工作台、管理后台由 SiteChrome 控制不渲染,避免与锁视口布局冲突。
|
||||
* 按 SiteBrand.mobile_tabs 配置顺序渲染(消息在未登录/未开放时不显示)。
|
||||
* SiteChrome 只在启用 tab 的根路径渲染本组件,
|
||||
* 避免与聊天 / 发帖工作台的锁视口布局冲突。
|
||||
*/
|
||||
export default function MobileTabBar({
|
||||
user,
|
||||
@@ -32,8 +34,8 @@ export default function MobileTabBar({
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const allowMessages = useAllowMessages();
|
||||
const urlStyle = useUrlStyle();
|
||||
const lbHref = leaderboardPath(urlStyle);
|
||||
const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
|
||||
const libHref = libraryPath(urlStyle);
|
||||
const [chatUnread, setChatUnread] = useState(initialChatUnread);
|
||||
|
||||
// 登录态经 router.refresh() 下发新 props 时校正徽标
|
||||
@@ -80,8 +82,55 @@ export default function MobileTabBar({
|
||||
const isActive = (match: (p: string) => boolean) =>
|
||||
!!pathname && match(pathname);
|
||||
|
||||
// 配置已由 SiteBrandProvider normalize(白名单内子集,空回落全量);数组顺序即展示顺序
|
||||
const tabNodes: Record<MobileTabKey, React.ReactNode> = {
|
||||
home: (
|
||||
<TabLink
|
||||
href="/"
|
||||
icon={<Home size={20} />}
|
||||
label="首页"
|
||||
active={isActive((p) => p === "/")}
|
||||
/>
|
||||
),
|
||||
library: (
|
||||
<TabLink
|
||||
href={libHref}
|
||||
icon={<BookOpen size={20} />}
|
||||
label="书库"
|
||||
active={isActive((p) => p === libHref)}
|
||||
/>
|
||||
),
|
||||
compose: (
|
||||
<TabLink
|
||||
href={user ? "/compose" : "/login"}
|
||||
icon={<PenSquare size={18} />}
|
||||
label="发帖"
|
||||
prominent
|
||||
active={false}
|
||||
/>
|
||||
),
|
||||
messages: showMessages ? (
|
||||
<TabLink
|
||||
href="/chat"
|
||||
icon={<MessageSquare size={20} />}
|
||||
label="消息"
|
||||
active={isActive((p) => p === "/chat")}
|
||||
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
|
||||
/>
|
||||
) : null,
|
||||
me: (
|
||||
<TabLink
|
||||
href={user ? userPath(urlStyle, user.id) : "/login"}
|
||||
icon={<UserIcon size={20} />}
|
||||
label="我的"
|
||||
active={!!user && isActive((p) => p === userPath(urlStyle, user.id))}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
data-j13-tabbar
|
||||
className="lg:hidden fixed bottom-0 inset-x-0 z-50 border-t"
|
||||
aria-label="底部导航"
|
||||
style={{
|
||||
@@ -93,49 +142,9 @@ export default function MobileTabBar({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
<TabLink
|
||||
href="/"
|
||||
icon={<Home size={20} />}
|
||||
label="首页"
|
||||
active={isActive((p) => p === "/")}
|
||||
/>
|
||||
<TabLink
|
||||
href={lbHref}
|
||||
icon={<Trophy size={20} />}
|
||||
label="排行榜"
|
||||
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
|
||||
/>
|
||||
<TabLink
|
||||
href={user ? "/compose" : "/login"}
|
||||
icon={<PenSquare size={18} />}
|
||||
label="发帖"
|
||||
prominent
|
||||
active={false}
|
||||
/>
|
||||
{showMessages && (
|
||||
<TabLink
|
||||
href="/chat"
|
||||
icon={<MessageSquare size={20} />}
|
||||
label="消息"
|
||||
active={isActive((p) => p.startsWith("/chat"))}
|
||||
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
|
||||
/>
|
||||
{mobileTabs.map((key) =>
|
||||
tabNodes[key] ? <Fragment key={key}>{tabNodes[key]}</Fragment> : null,
|
||||
)}
|
||||
<TabLink
|
||||
href={user ? userPath(urlStyle, user.id) : "/login"}
|
||||
icon={<UserIcon size={20} />}
|
||||
label="我的"
|
||||
active={
|
||||
!!user &&
|
||||
isActive((p) => {
|
||||
const parsed = parseEntityPath(p);
|
||||
return (
|
||||
(parsed?.kind === "user" && parsed.id === user.id) ||
|
||||
p.startsWith(`${userPath(urlStyle, user.id)}/`)
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -213,7 +213,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in flex flex-col
|
||||
className="j13-notif-sheet absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in flex flex-col
|
||||
max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:top-auto max-lg:mt-0 max-lg:w-full max-lg:max-w-none max-lg:max-h-[75dvh] max-lg:rounded-t-2xl!"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function Pagination({ total, page, size, basePath, query = {}, on
|
||||
"pager-btn min-w-[38px] h-9 px-1 flex items-center justify-center font-semibold text-[13px] rounded-full border transition-colors duration-150";
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-center gap-1.5 mt-10 flex-wrap" aria-label="分页">
|
||||
<nav className="flex items-center justify-center gap-1.5 mt-6 flex-wrap" aria-label="分页">
|
||||
{page > 1 && (
|
||||
<Link
|
||||
href={buildHref(page - 1)}
|
||||
|
||||
@@ -16,7 +16,15 @@ export interface TocItem {
|
||||
* - IntersectionObserver 高亮当前小节;
|
||||
* - 当前项被滚动到目录可视区外时,列表自动最小化跟随(首屏不滚动、尊重 reduced-motion)。
|
||||
*/
|
||||
export default function PostToc({ items }: { items: TocItem[] }) {
|
||||
export default function PostToc({
|
||||
items,
|
||||
title = "目录",
|
||||
bare = false,
|
||||
}: {
|
||||
items: TocItem[];
|
||||
title?: string;
|
||||
bare?: boolean;
|
||||
}) {
|
||||
const [active, setActive] = useState<string>(items[0]?.id ?? "");
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
// 首次挂载时 active 取首项,不触发跟随滚动,避免进页跳动
|
||||
@@ -38,15 +46,25 @@ export default function PostToc({ items }: { items: TocItem[] }) {
|
||||
return () => obs.disconnect();
|
||||
}, [items]);
|
||||
|
||||
// active 变化时把当前目录项滚入列表可视区(block: nearest 仅在贴边时最小移动)
|
||||
// active 变化时把当前目录项滚入列表可视区(仅在列表容器内最小滚动)。
|
||||
// 不用 scrollIntoView:移动端目录下沉到文末(普通文档流、容器本身不可滚),
|
||||
// scrollIntoView(nearest) 会连带滚动窗口,把正在阅读的页面拽到底部目录,
|
||||
// 表现为"进页自动下滚 / 无法向上滑动"。
|
||||
useEffect(() => {
|
||||
if (skipFollow.current) {
|
||||
skipFollow.current = false;
|
||||
return;
|
||||
}
|
||||
const el = navRef.current?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ block: "nearest", behavior: scrollBehavior() });
|
||||
const list = navRef.current?.querySelector<HTMLElement>(".j13-rail-scroll");
|
||||
const el = list?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
|
||||
if (!list || !el) return;
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
if (elRect.top < listRect.top) {
|
||||
list.scrollBy({ top: elRect.top - listRect.top, behavior: scrollBehavior() });
|
||||
} else if (elRect.bottom > listRect.bottom) {
|
||||
list.scrollBy({ top: elRect.bottom - listRect.bottom, behavior: scrollBehavior() });
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
const jump = (id: string) => (e: React.MouseEvent) => {
|
||||
@@ -59,16 +77,29 @@ export default function PostToc({ items }: { items: TocItem[] }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<nav ref={navRef} aria-label="文章目录" className="panel p-4 flex min-h-0 flex-col">
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label="文章目录"
|
||||
className={`${bare ? "py-4" : "panel p-4"} flex min-h-0 flex-col`}
|
||||
style={
|
||||
bare
|
||||
? {
|
||||
background: "var(--panel)",
|
||||
borderTop: "1px solid var(--line)",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<p
|
||||
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2.5 px-1 shrink-0"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
目录
|
||||
{title}
|
||||
</p>
|
||||
<ul
|
||||
className="j13-rail-scroll space-y-0.5 pr-0.5"
|
||||
style={{ borderLeft: "1px solid var(--line)" }}
|
||||
className="j13-rail-scroll min-h-0 flex-1 space-y-0.5 pr-0.5"
|
||||
style={bare ? undefined : { borderLeft: "1px solid var(--line)" }}
|
||||
>
|
||||
{items.map((it) => {
|
||||
const isActive = it.id === active;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState, type R
|
||||
import { apiGetSettings, type PublicSettings } from "@/lib/api";
|
||||
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
|
||||
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
|
||||
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
|
||||
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "@/lib/headerNav";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
|
||||
|
||||
@@ -19,6 +21,8 @@ export type SiteBrand = {
|
||||
brand_logo_fit: PublicSettings["brand_logo_fit"];
|
||||
footer_links: PublicSettings["footer_links"];
|
||||
url_style: UrlStyle;
|
||||
mobile_tabs: MobileTabKey[];
|
||||
header_nav: HeaderNavItem[];
|
||||
};
|
||||
|
||||
const SiteBrandContext = createContext<SiteBrand | null>(null);
|
||||
@@ -36,6 +40,8 @@ function fromSettings(s: PublicSettings): SiteBrand {
|
||||
brand_logo_fit: s.brand_logo_fit,
|
||||
footer_links: s.footer_links,
|
||||
url_style: s.url_style,
|
||||
mobile_tabs: s.mobile_tabs,
|
||||
header_nav: s.header_nav,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +57,9 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
|
||||
a.brand_logo_size === b.brand_logo_size &&
|
||||
a.brand_logo_fit === b.brand_logo_fit &&
|
||||
a.url_style === b.url_style &&
|
||||
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links)
|
||||
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) &&
|
||||
JSON.stringify(a.mobile_tabs) === JSON.stringify(b.mobile_tabs) &&
|
||||
JSON.stringify(a.header_nav) === JSON.stringify(b.header_nav)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +110,8 @@ export function useSiteBrand(): SiteBrand {
|
||||
brand_logo_fit: "contain",
|
||||
footer_links: [],
|
||||
url_style: "default",
|
||||
mobile_tabs: [...DEFAULT_MOBILE_TABS],
|
||||
header_nav: DEFAULT_HEADER_NAV.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
@@ -153,6 +163,8 @@ export default function SiteBrandProvider({
|
||||
? normalizeFooterLinks(raw.footer_links)
|
||||
: prev.footer_links,
|
||||
url_style: raw.url_style !== undefined ? normalizeUrlStyle(raw.url_style) : prev.url_style,
|
||||
mobile_tabs: raw.mobile_tabs !== undefined ? normalizeMobileTabs(raw.mobile_tabs) : prev.mobile_tabs,
|
||||
header_nav: raw.header_nav !== undefined ? normalizeHeaderNav(raw.header_nav) : prev.header_nav,
|
||||
};
|
||||
if (sameBrand(prev, next)) return;
|
||||
retitle(prev, next);
|
||||
|
||||
@@ -7,6 +7,8 @@ import Footer from "@/components/Footer";
|
||||
import BackToTop from "@/components/BackToTop";
|
||||
import MobileTabBar from "@/components/MobileTabBar";
|
||||
import type { User } from "@/lib/api";
|
||||
import { isWideShellPath } from "@/lib/siteUrl";
|
||||
import { libraryPath, userPath } from "@/lib/urlStyle";
|
||||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||||
|
||||
function pathIsAdmin(pathname: string | null) {
|
||||
@@ -24,11 +26,6 @@ function pathIsComposeStudio(pathname: string | null) {
|
||||
return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
/** 底部公共菜单避让:聊天 / 发帖工作台锁视口,不渲染底栏 */
|
||||
function pathHasNoTabBar(pathname: string | null) {
|
||||
return pathIsChat(pathname) || pathIsComposeStudio(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开站 chrome 闸门:/admin 不渲染 Header/Footer/1440 main。
|
||||
* 首屏用 middleware 的 x-pathname 初值保证 hydration 一致;
|
||||
@@ -85,7 +82,19 @@ export default function SiteChrome({
|
||||
|
||||
// 聊天 / 发帖工作台锁视口,窗口回顶无意义
|
||||
const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname);
|
||||
const showTabBar = showBackToTop;
|
||||
// 底栏只在已启用 tab 的根路径显示(个人页子 tab 走 ?tab= 查询参数,不影响 pathname);
|
||||
// 发帖无「首页」概念不产生可见路径,发帖工作台 / 帖子编辑页继续排除
|
||||
const enabledTabs = new Set(brand.mobile_tabs);
|
||||
const meHref = user ? userPath(brand.url_style, user.id) : "";
|
||||
const tabBarRoots: string[] = [];
|
||||
if (enabledTabs.has("home")) tabBarRoots.push("/");
|
||||
if (enabledTabs.has("library")) tabBarRoots.push(libraryPath(brand.url_style));
|
||||
if (enabledTabs.has("messages")) tabBarRoots.push("/chat");
|
||||
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
|
||||
const showTabBar =
|
||||
!!pathname && !pathIsComposeStudio(pathname) && tabBarRoots.includes(pathname);
|
||||
// 书库页正文区放宽(Header/Footer 保持 1440,避免顶栏菜单位置随页面跳动)
|
||||
const wideShell = isWideShellPath(pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -105,7 +114,7 @@ export default function SiteChrome({
|
||||
<main
|
||||
id="main"
|
||||
tabIndex={-1}
|
||||
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[calc(4.75rem+env(safe-area-inset-bottom))] lg:pb-[var(--main-pad-y)] outline-none"
|
||||
className={`flex-1 w-full ${wideShell ? "max-w-[1680px]" : "max-w-[1440px]"} mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[var(--main-pad-y)] outline-none`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Plus,
|
||||
Tag,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
serializeTimelineBlock,
|
||||
type TimelineItem,
|
||||
} from "@/lib/timelineBlocks";
|
||||
import { apiTimelineFromGit, type TimelineGitItem } from "@/lib/api";
|
||||
import { apiTimelineFromGit, apiTimelineFromReleases, type TimelineGitItem } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
type ImportMode = "replace" | "append";
|
||||
@@ -108,11 +109,34 @@ export default function TimelineEditor({
|
||||
);
|
||||
};
|
||||
|
||||
const runImport = async () => {
|
||||
const urls = urlsText
|
||||
const collectURLs = () =>
|
||||
urlsText
|
||||
.split(/\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const applyImported = (mapped: TimelineItem[], errorHint: string) => {
|
||||
if (mapped.length === 0) {
|
||||
setImportError(errorHint);
|
||||
return false;
|
||||
}
|
||||
setItems((prev) => {
|
||||
if (importMode === "append") {
|
||||
const seen = new Set(
|
||||
prev.map((p) => p.sourceUrl || `${p.date}|${p.title}`).filter(Boolean)
|
||||
);
|
||||
const extra = mapped.filter(
|
||||
(m) => !m.sourceUrl || !seen.has(m.sourceUrl)
|
||||
);
|
||||
return [...prev.filter((p) => p.title.trim() || p.body.trim()), ...extra];
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const runImportCommits = async () => {
|
||||
const urls = collectURLs();
|
||||
if (urls.length === 0) {
|
||||
setImportError("请粘贴至少一条提交记录页面 URL");
|
||||
return;
|
||||
@@ -122,23 +146,10 @@ export default function TimelineEditor({
|
||||
try {
|
||||
const res = await apiTimelineFromGit({ urls, follow_pages: followPages });
|
||||
const mapped = (res.items || []).map(fromGitItem);
|
||||
if (mapped.length === 0) {
|
||||
setImportError(res.error || "未能解析出提交;请检查 URL 或改为手填");
|
||||
if (!applyImported(mapped, res.error || "未能解析出提交;请检查 URL 或改为手填")) {
|
||||
if (res.warning) toast(res.warning);
|
||||
return;
|
||||
}
|
||||
setItems((prev) => {
|
||||
if (importMode === "append") {
|
||||
const seen = new Set(
|
||||
prev.map((p) => p.sourceUrl || `${p.date}|${p.title}`).filter(Boolean)
|
||||
);
|
||||
const extra = mapped.filter(
|
||||
(m) => !m.sourceUrl || !seen.has(m.sourceUrl)
|
||||
);
|
||||
return [...prev.filter((p) => p.title.trim() || p.body.trim()), ...extra];
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
if (res.warning) toast(res.warning, "ok");
|
||||
else toast(`已导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
|
||||
if (res.error) setImportError(res.error);
|
||||
@@ -149,6 +160,31 @@ export default function TimelineEditor({
|
||||
}
|
||||
};
|
||||
|
||||
const runImportReleases = async () => {
|
||||
const urls = collectURLs();
|
||||
if (urls.length === 0) {
|
||||
setImportError("请粘贴至少一条 Release 页面 URL");
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
const res = await apiTimelineFromReleases({ urls });
|
||||
const mapped = (res.items || []).map(fromGitItem);
|
||||
if (!applyImported(mapped, res.error || "未能解析出 Release;请检查 URL 或改为手填")) {
|
||||
if (res.warning) toast(res.warning);
|
||||
return;
|
||||
}
|
||||
if (res.warning) toast(res.warning, "ok");
|
||||
else toast(`已从 Release 导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
|
||||
if (res.error) setImportError(res.error);
|
||||
} catch (e) {
|
||||
setImportError(e instanceof Error ? e.message : "导入失败,请手填时间线");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInsert = () => {
|
||||
const kept = items.filter((it) => it.selected !== false);
|
||||
if (kept.length === 0) {
|
||||
@@ -222,7 +258,7 @@ export default function TimelineEditor({
|
||||
</p>
|
||||
</div>
|
||||
<p className="meta text-[12px] mb-2 leading-relaxed">
|
||||
粘贴 Gitea / GitHub 的 commits 列表页 URL,每行一条。默认自动拉取后续页,也可只贴某几页。
|
||||
粘贴 Gitea / GitHub 的 commits 或 releases 列表页 URL,每行一条。点「导入提交」拉取提交记录,点「导入 Release」拉取发布日志。
|
||||
</p>
|
||||
<textarea
|
||||
value={urlsText}
|
||||
@@ -231,7 +267,7 @@ export default function TimelineEditor({
|
||||
setImportError(null);
|
||||
}}
|
||||
rows={3}
|
||||
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
|
||||
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.iioio.com/freefire/jiang13-bbs/releases"}
|
||||
className="j13-tl-urls"
|
||||
disabled={importing}
|
||||
/>
|
||||
@@ -260,20 +296,40 @@ export default function TimelineEditor({
|
||||
<option value="append">追加</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm ml-auto"
|
||||
disabled={importing}
|
||||
onClick={() => void runImport()}
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" /> 导入中…
|
||||
</>
|
||||
) : (
|
||||
"导入"
|
||||
)}
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={importing}
|
||||
onClick={() => void runImportCommits()}
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" /> 导入中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitBranch size={14} /> 导入提交
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={importing}
|
||||
onClick={() => void runImportReleases()}
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" /> 导入中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag size={14} /> 导入 Release
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showReplaceWarning ? (
|
||||
<p className="j13-tl-banner is-warn">
|
||||
@@ -300,7 +356,7 @@ export default function TimelineEditor({
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setItems((prev) => [...prev, emptyItem()])}
|
||||
onClick={() => setItems((prev) => [emptyItem(), ...prev])}
|
||||
>
|
||||
<Plus size={14} /> 添加
|
||||
</button>
|
||||
@@ -408,6 +464,7 @@ export default function TimelineEditor({
|
||||
title="删除条目"
|
||||
message="确定删除这条时间线项目?"
|
||||
danger
|
||||
zIndexClass="z-[1300]"
|
||||
confirmLabel="删除"
|
||||
onCancel={() => setDeleteIdx(null)}
|
||||
onConfirm={() => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
BookOpen,
|
||||
CircleDollarSign,
|
||||
Columns3,
|
||||
PanelLeft,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
Award,
|
||||
Layers,
|
||||
Images,
|
||||
Link2,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
@@ -108,6 +110,18 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: FileText,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/library",
|
||||
label: "书库管理",
|
||||
icon: BookOpen,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/friend-links",
|
||||
label: "友情链接",
|
||||
icon: Link2,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -66,10 +66,14 @@ export function AdminChartCard({
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
{/* 捕获阶段拦截 mousedown:冒泡阶段太晚,浏览器已开始 SVG 多柱选区黑框 */}
|
||||
{/* 捕获阶段拦截 SVG 内 mousedown:冒泡阶段太晚,浏览器已开始选区黑框;非 SVG(输入框/表格)不拦截,否则无法聚焦输入 */}
|
||||
{/* overflow-hidden 会沿自身 padding box 裁剪:px/p- 相同值负 margin 外扩裁剪边界 3px(内容宽度不变),
|
||||
避免贴边子元素(如明细工具条首个搜索框)的 2px 聚焦光环被裁掉 */}
|
||||
<div
|
||||
className="w-full overflow-hidden select-none [&_*]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
|
||||
onMouseDownCapture={(e) => e.preventDefault()}
|
||||
className="overflow-hidden px-[3px] -mx-[3px] [&_svg]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
|
||||
onMouseDownCapture={(e) => {
|
||||
if ((e.target as Element).closest("svg")) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -26,13 +26,13 @@ import type {
|
||||
SidebarPageItem,
|
||||
PublicAdItem,
|
||||
PublicSponsorItem,
|
||||
FriendLink,
|
||||
} from "@/lib/api";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { AdText } from "@/components/AdText";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { announcementPath, postPath, userPath, pagePath } from "@/lib/urlStyle";
|
||||
import { PulseCheckin } from "./CheckinCard";
|
||||
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
||||
import { formatCompact, formatShortDate } from "@/lib/format";
|
||||
import { pageGlyph } from "@/lib/pageGlyph";
|
||||
import { AboutSiteHeader } from "@/components/site-doc";
|
||||
@@ -396,38 +396,53 @@ export async function ActiveUsers({ users, allowComments }: { users: ActiveUser[
|
||||
|
||||
/* ---------- 友情链接 ---------- */
|
||||
|
||||
export function FriendLinks() {
|
||||
if (FRIEND_LINKS.length === 0) return null;
|
||||
export function FriendLinks({ links }: { links: FriendLink[] }) {
|
||||
// 块显隐由侧栏布局配置控制;列表为空时仍渲染(保留「申请」入口),仅展示占位提示
|
||||
return (
|
||||
<section className="j13-module" aria-label="友情链接">
|
||||
<p className="j13-module-title mb-3">
|
||||
<Link2 size={15} style={{ color: "var(--accent)" }} /> 友情链接
|
||||
<Link
|
||||
href="/compose"
|
||||
className="meta ml-auto text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
|
||||
>
|
||||
申请
|
||||
</Link>
|
||||
<span className="ml-auto inline-flex items-center gap-3 text-[12px] font-normal">
|
||||
<Link href="/links" className="meta transition-colors hover:text-[var(--accent)]">
|
||||
全部
|
||||
</Link>
|
||||
<Link
|
||||
href="/links#apply"
|
||||
className="meta transition-colors hover:text-[var(--accent)]"
|
||||
>
|
||||
申请
|
||||
</Link>
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FRIEND_LINKS.map((l) =>
|
||||
l.href.startsWith("http") ? (
|
||||
{links.length === 0 ? (
|
||||
<p className="meta text-[12.5px]">暂无友链,欢迎申请</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{links.map((l) => (
|
||||
<a
|
||||
key={l.name}
|
||||
href={l.href}
|
||||
key={l.id}
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="j13-chip"
|
||||
className="j13-chip inline-flex items-center gap-1.5"
|
||||
title={l.description || l.name}
|
||||
>
|
||||
{l.logo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={l.logo_url}
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
className="object-contain"
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
) : null}
|
||||
{l.name}
|
||||
</a>
|
||||
) : (
|
||||
<span key={l.name} className="j13-chip" data-tip="友链待配置">
|
||||
{l.name}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -452,6 +467,7 @@ type PanelsProps = {
|
||||
sponsors?: PublicSponsorItem[];
|
||||
sponsorsPanelTitle?: string;
|
||||
sponsorsEnabled?: boolean;
|
||||
friendLinks?: FriendLink[];
|
||||
};
|
||||
|
||||
export function AdsPanel({
|
||||
@@ -615,6 +631,7 @@ function CommunityPanelsBody({
|
||||
sponsors = [],
|
||||
sponsorsPanelTitle = "赞助商",
|
||||
sponsorsEnabled = true,
|
||||
friendLinks = [],
|
||||
}: PanelsProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -625,7 +642,7 @@ function CommunityPanelsBody({
|
||||
<HotRanking posts={hot} allowComments={allowComments} />
|
||||
<NewUsers users={newUsers} />
|
||||
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
||||
<FriendLinks />
|
||||
<FriendLinks links={friendLinks} />
|
||||
<AboutSite
|
||||
pages={sidebarPages}
|
||||
siteDescription={siteDescription}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Home, ShieldCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Bookmark, Home, LayoutGrid, Menu, ShieldCheck } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { userPath } from "@/lib/urlStyle";
|
||||
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
|
||||
|
||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||
function boardHref(id: number) {
|
||||
@@ -116,13 +117,27 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载
|
||||
export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
|
||||
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载。
|
||||
// 板块条两端嵌左右抽屉入口(☰ 导航 / ▦ 面板),与书籍阅读页的左右工具栏同构:
|
||||
// 左抽屉 = 桌面左栏(板块目录 + 工作台),右抽屉 = 桌面右栏面板;内容为空则对应入口不渲染
|
||||
export function WorkspaceNavMobile({
|
||||
boards,
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
boards: BoardWithCount[];
|
||||
/** 左抽屉内容(桌面左栏,服务端渲染节点) */
|
||||
left?: ReactNode;
|
||||
/** 右抽屉内容(桌面右栏,服务端渲染节点) */
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [drawer, setDrawer] = useState<"left" | "right" | null>(null);
|
||||
useDrawerChrome(drawer !== null, () => setDrawer(null));
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -151,43 +166,88 @@ export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
|
||||
});
|
||||
}, [activeBoard, boards]);
|
||||
|
||||
const triggerBtn =
|
||||
"relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border transition-colors hover:bg-[var(--panel-2)] active:scale-95 " +
|
||||
// 视觉 28px、触控热区扩到 44px(HIG 最小触控目标),仅扩大命中范围不改变外观
|
||||
"before:absolute before:-inset-2 before:rounded-full before:content-['']";
|
||||
|
||||
return (
|
||||
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 bg-[var(--main-bg)] border-b border-[var(--line)] py-2.5">
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="j13-chips"
|
||||
onScroll={updateScrollState}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
{left ? (
|
||||
<button
|
||||
type="button"
|
||||
className={triggerBtn}
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
|
||||
onClick={() => setDrawer("left")}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="打开导航抽屉"
|
||||
>
|
||||
<Menu size={15} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="j13-chips"
|
||||
onScroll={updateScrollState}
|
||||
>
|
||||
<Home size={13} /> 全部
|
||||
</Link>
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
href="/"
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={13} />
|
||||
{b.name}
|
||||
<Home size={13} /> 全部
|
||||
</Link>
|
||||
))}
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={13} />
|
||||
{b.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
|
||||
<div
|
||||
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
|
||||
<div
|
||||
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{right ? (
|
||||
<button
|
||||
type="button"
|
||||
className={triggerBtn}
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
|
||||
onClick={() => setDrawer("right")}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="打开面板抽屉"
|
||||
>
|
||||
<LayoutGrid size={15} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 左右抽屉:内容由服务端渲染后传入;lg 起隐藏(桌面用常驻侧栏) */}
|
||||
{left ? (
|
||||
<RailDrawerPanel side="left" title="导航" open={drawer === "left"} onClose={() => setDrawer(null)}>
|
||||
{left}
|
||||
</RailDrawerPanel>
|
||||
) : null}
|
||||
{right ? (
|
||||
<RailDrawerPanel side="right" title="面板" open={drawer === "right"} onClose={() => setDrawer(null)}>
|
||||
{right}
|
||||
</RailDrawerPanel>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
197
frontend/components/markdown/MdEntries.tsx
Normal file
197
frontend/components/markdown/MdEntries.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown";
|
||||
import { ENTRY_TITLE_NUM_RE, type EntryItem } from "@/lib/entryBlocks";
|
||||
|
||||
/** 标签 chip:"主:次" 或 "主=次" 形式渲染主次两段,否则单段;不做语义配色,保持字段通用 */
|
||||
function EntryTag({ tag }: { tag: string }) {
|
||||
const idx = tag.search(/[::=]/);
|
||||
const main = idx >= 0 ? tag.slice(0, idx) : tag;
|
||||
const sub = idx >= 0 ? tag.slice(idx + 1) : "";
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-baseline gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] leading-5"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="font-semibold" style={{ color: "var(--ink-2)" }}>
|
||||
{main}
|
||||
</span>
|
||||
{sub ? (
|
||||
<span style={{ color: "var(--ink-3)" }}>{sub}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目卡纯展示视图:编号标题 + 标签 chips + 高亮摘要 + 通用字段行 + 折叠来源。
|
||||
* 字段名不限定(成本/收益/备注只是书写约定),任何书籍可定义自己的字段;
|
||||
* markdown 片段由调用方注入(服务端 renderMarkdownToReact / 客户端 ReactMarkdown 预览)。
|
||||
*/
|
||||
export function MdEntriesView({
|
||||
items,
|
||||
renderMd,
|
||||
titleIds,
|
||||
}: {
|
||||
items: EntryItem[];
|
||||
renderMd: (text: string) => ReactNode;
|
||||
/** 与目录共享的标题锚点 id(按条目顺序;缺省不挂 id) */
|
||||
titleIds?: (string | undefined)[];
|
||||
}) {
|
||||
return (
|
||||
<div className="my-4 -mx-2 flex flex-col gap-4 sm:-mx-3">
|
||||
{items.map((it, i) => (
|
||||
<article
|
||||
key={i}
|
||||
className="j13-entry-card rounded-2xl px-3 py-4 sm:px-4 sm:py-5"
|
||||
style={{
|
||||
border: "1px solid var(--line)",
|
||||
// 极浅灰卡底:panel-2 半量混入 panel,深浅色模式各自成立
|
||||
background: "color-mix(in srgb, var(--panel-2) 50%, var(--panel))",
|
||||
}}
|
||||
>
|
||||
{!it.plain ? (
|
||||
<h2
|
||||
id={titleIds?.[i]}
|
||||
className="flex items-baseline gap-2.5"
|
||||
style={{
|
||||
color: "var(--ink)",
|
||||
scrollMarginTop: 90,
|
||||
// 二级标题字号(移动端经 --entry-title-size 收小,见 globals.css);
|
||||
// 覆盖 .prose-content h2 的外边距与底边线(卡内不需要)
|
||||
fontSize: "var(--entry-title-size, 1.4rem)",
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.35,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
{ENTRY_TITLE_NUM_RE.test(it.title) ? null : (
|
||||
<span
|
||||
className="tabular-nums text-[12.5px] font-semibold shrink-0"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">{it.title}</span>
|
||||
</h2>
|
||||
) : null}
|
||||
|
||||
{it.tags.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{it.tags.map((tag, ti) => (
|
||||
<EntryTag key={ti} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{it.summary?.trim() ? (
|
||||
<div
|
||||
className="j13-entry-summary mt-3 rounded-xl px-3.5 py-3 text-[14px] leading-relaxed [&>p]:my-0"
|
||||
style={{
|
||||
background: "var(--accent-soft)",
|
||||
borderLeft: "3px solid var(--accent)",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{renderMd(it.summary)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{it.fields.length > 0 ? (
|
||||
<dl className="mt-3 flex flex-col gap-2.5">
|
||||
{it.fields.map((f, fi) => (
|
||||
<div
|
||||
key={fi}
|
||||
className="grid grid-cols-1 gap-y-0.5 sm:grid-cols-[76px_minmax(0,1fr)] sm:gap-x-3 sm:gap-y-0"
|
||||
>
|
||||
<dt className="text-[12.5px] leading-[22px]" style={{ color: "var(--ink-3)" }}>
|
||||
{f.label}
|
||||
</dt>
|
||||
<dd
|
||||
className="min-w-0 text-[14px] leading-relaxed [&>p]:my-0 [&>ul]:my-1 [&>ol]:my-1"
|
||||
style={{
|
||||
// 正文用正常墨色;备注/来源类说明性字段保持浅灰
|
||||
color:
|
||||
f.label === "备注" || f.label === "来源"
|
||||
? "var(--ink-2)"
|
||||
: "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{f.value.trim() ? renderMd(f.value) : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
|
||||
{it.sources.length > 0 ? (
|
||||
<details className="group mt-3">
|
||||
<summary
|
||||
className="inline-flex cursor-pointer select-none list-none items-center gap-1 text-[12.5px] font-medium [&::-webkit-details-marker]:hidden"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className="transition-transform group-open:rotate-90"
|
||||
aria-hidden
|
||||
/>
|
||||
来源 · {it.sources.length} 条
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{it.sources.map((s, si) => (
|
||||
<div
|
||||
key={si}
|
||||
className="flex gap-2 text-[12.5px] leading-relaxed"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
|
||||
{si + 1}.
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span>{s.text}</span>
|
||||
{s.url ? (
|
||||
<a
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="block break-all hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
{s.url}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 服务端条目卡:markdown 片段走站点统一渲染管线(Shiki 等) */
|
||||
export default async function MdEntries({
|
||||
items,
|
||||
titleIds,
|
||||
}: {
|
||||
items: EntryItem[];
|
||||
titleIds?: (string | undefined)[];
|
||||
}) {
|
||||
const cache = new Map<string, ReactNode>();
|
||||
await Promise.all(
|
||||
items.flatMap((it) => {
|
||||
const put = async (text?: string) => {
|
||||
if (!text?.trim() || cache.has(text)) return;
|
||||
cache.set(text, await renderMarkdownToReact(text));
|
||||
};
|
||||
return [put(it.summary), ...it.fields.map((f) => put(f.value))];
|
||||
})
|
||||
);
|
||||
return <MdEntriesView items={items} titleIds={titleIds} renderMd={(t) => cache.get(t) ?? null} />;
|
||||
}
|
||||
@@ -48,34 +48,44 @@ export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
|
||||
style={{ scrollMarginTop: 90 }}
|
||||
aria-labelledby={`${timelineDayId(g.date)}-label`}
|
||||
>
|
||||
<div className="j13-timeline-day-label" id={`${timelineDayId(g.date)}-label`}>
|
||||
<time dateTime={/^\d{4}-\d{2}-\d{2}$/.test(g.date) ? g.date : undefined}>
|
||||
{g.date}
|
||||
</time>
|
||||
<span className="j13-timeline-day-count">{g.items.length}</span>
|
||||
</div>
|
||||
<ul className="j13-timeline-items">
|
||||
{g.items.map((it) => {
|
||||
const idx = flat++;
|
||||
{g.items.map((it, idx) => {
|
||||
const flatIdx = flat++;
|
||||
const { kind, scope, rest } = splitTitlePrefix(it.title);
|
||||
const bodyNode = renderBody
|
||||
? renderBody(it, idx)
|
||||
? renderBody(it, flatIdx)
|
||||
: it.body.trim()
|
||||
? <span className="whitespace-pre-wrap">{it.body}</span>
|
||||
: null;
|
||||
const isFirstInDay = idx === 0;
|
||||
|
||||
return (
|
||||
<li key={`${g.date}-${idx}-${it.title}`} className="j13-timeline-item" role="listitem">
|
||||
<li
|
||||
key={`${g.date}-${flatIdx}-${it.title}`}
|
||||
className={`j13-timeline-item${isFirstInDay ? " is-first-in-day" : ""}`}
|
||||
role="listitem"
|
||||
>
|
||||
<span className="j13-timeline-dot" aria-hidden />
|
||||
<div className="j13-timeline-card">
|
||||
<div className="j13-timeline-head">
|
||||
{isFirstInDay ? (
|
||||
<div
|
||||
className="j13-timeline-day-label"
|
||||
id={`${timelineDayId(g.date)}-label`}
|
||||
>
|
||||
<time dateTime={/^\d{4}-\d{2}-\d{2}$/.test(g.date) ? g.date : undefined}>
|
||||
{g.date}
|
||||
</time>
|
||||
<span className="j13-timeline-day-count">{g.items.length}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="j13-timeline-summary">{rest}</p>
|
||||
{kind ? (
|
||||
<span className={`j13-timeline-chip is-${kind}`}>
|
||||
{kind}
|
||||
{scope ? <span className="j13-timeline-chip-scope">({scope})</span> : null}
|
||||
</span>
|
||||
) : null}
|
||||
<p className="j13-timeline-summary">{rest}</p>
|
||||
{it.sourceUrl ? (
|
||||
<a
|
||||
href={it.sourceUrl}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BoardSidebarStats,
|
||||
BoardWithCount,
|
||||
CheckinStatus,
|
||||
FriendLink,
|
||||
NewUser,
|
||||
OverviewStats,
|
||||
PostListItem,
|
||||
@@ -62,6 +63,7 @@ export interface SidebarBlockProps {
|
||||
hot?: PostListItem[];
|
||||
newUsers?: NewUser[];
|
||||
activeUsers?: ActiveUser[];
|
||||
friendLinks?: FriendLink[];
|
||||
sidebarPages?: SidebarPageItem[];
|
||||
siteDescription?: string;
|
||||
canManagePages?: boolean;
|
||||
@@ -117,7 +119,8 @@ export function renderSidebarBlock(key: string, p: SidebarBlockProps) {
|
||||
<ActiveUsers users={p.activeUsers} allowComments={p.allowComments} />
|
||||
) : null;
|
||||
case "friend_links":
|
||||
return <FriendLinks />;
|
||||
// 空列表也渲染(保留申请入口);显隐由侧栏布局配置控制
|
||||
return <FriendLinks links={p.friendLinks ?? []} />;
|
||||
case "about_site":
|
||||
return p.sidebarPages || p.canManagePages ? (
|
||||
<AboutSite
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
import MobileRailDrawers from "@/components/MobileRailDrawers";
|
||||
|
||||
/**
|
||||
* 公文阅读三栏壳:宽度与帖子详情对齐。
|
||||
* lg = 左224 + 中栏 + 右280;xl = 左240 + 中栏 + 右300;窄屏仅中栏,侧栏下沉。
|
||||
* lg = 左224 + 中栏 + 右280;xl = 左240 + 中栏 + 右300;
|
||||
* 窄屏仅中栏,左右两栏收进移动端抽屉(工具栏按钮:导航 / 目录)。
|
||||
*/
|
||||
export default function SiteDocShell({
|
||||
left,
|
||||
right,
|
||||
children,
|
||||
mobileExtras,
|
||||
}: {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
children: ReactNode;
|
||||
/** 窄屏下沉到正文之后的侧栏副本 */
|
||||
mobileExtras?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* 移动端:左右抽屉(导航 / 目录),lg 起隐藏;两侧皆空时组件自身不渲染 */}
|
||||
<MobileRailDrawers
|
||||
left={left ? { label: "导航", content: left } : undefined}
|
||||
right={right ? { label: "目录", content: right } : undefined}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
|
||||
<aside
|
||||
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"
|
||||
@@ -26,7 +31,7 @@ export default function SiteDocShell({
|
||||
{left}
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="j13-doc-bleed min-w-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -37,8 +42,6 @@ export default function SiteDocShell({
|
||||
{right}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{mobileExtras ? <div className="lg:hidden mt-6 space-y-4">{mobileExtras}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "./brand";
|
||||
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
|
||||
import { normalizeUrlStyle, type UrlStyle } from "./urlStyle";
|
||||
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "./mobileTabs";
|
||||
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "./headerNav";
|
||||
import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies";
|
||||
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
|
||||
import { emitForceLogout } from "./userEvents";
|
||||
@@ -243,6 +245,7 @@ export interface SitePage {
|
||||
allow_comments: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted?: boolean; // 软删标记(仅管理端列表)
|
||||
}
|
||||
|
||||
export interface SitePageInput {
|
||||
@@ -255,6 +258,72 @@ export interface SitePageInput {
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
/** 书库文件(epub/pdf/html 等;StoredName 不出后端) */
|
||||
export interface LibraryFileItem {
|
||||
id: number;
|
||||
doc_id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
ext: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 书库章节(两级树:parent_id 空=章,非空=节;正文 markdown) */
|
||||
export interface LibrarySectionItem {
|
||||
id: number;
|
||||
doc_id: number;
|
||||
parent_id: number | null;
|
||||
title: string;
|
||||
content: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 书库条目(详情/管理端,含文件与章节) */
|
||||
export interface LibraryDoc {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
cover_url: string;
|
||||
published: boolean;
|
||||
sort_order: number;
|
||||
entries_auto?: boolean; // 全章节条目卡模式(章节内容免写 [entries] 壳)
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted?: boolean; // 软删标记(仅管理端列表)
|
||||
files: LibraryFileItem[];
|
||||
sections: LibrarySectionItem[];
|
||||
}
|
||||
|
||||
/** 书库公开目录条目(不含 description 全文) */
|
||||
export interface LibraryDocListItem {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
cover_url: string;
|
||||
sort_order: number;
|
||||
file_count: number;
|
||||
files: LibraryFileItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LibraryInput {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
cover_url?: string;
|
||||
published: boolean;
|
||||
sort_order?: number;
|
||||
entries_auto?: boolean;
|
||||
}
|
||||
|
||||
export interface NewUser {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -592,7 +661,7 @@ export interface UserProfile {
|
||||
badges?: UserBadgeView[];
|
||||
/** 访客(登录态)与该用户的关注关系;本人访问恒 false */
|
||||
is_following?: boolean;
|
||||
/** 仅本人访问时下发的打码邮箱(j***@163.com) */
|
||||
/** 仅本人访问时下发的邮箱明文(账号信息展示与资料表单回填) */
|
||||
email?: string;
|
||||
};
|
||||
stats: {
|
||||
@@ -1012,6 +1081,10 @@ export interface PublicSettings {
|
||||
levels_fx: boolean;
|
||||
/** 详情页伪静态风格;缺省 default(标准 /post/123) */
|
||||
url_style: UrlStyle;
|
||||
/** 移动端底栏显示的 tab(固定顺序子集);空/非法回落默认全量 */
|
||||
mobile_tabs: MobileTabKey[];
|
||||
/** 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项 */
|
||||
header_nav: HeaderNavItem[];
|
||||
}
|
||||
|
||||
/** 与后端 DefaultAttachmentExts 保持同步 */
|
||||
@@ -1077,6 +1150,8 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
levels: [...DEFAULT_LEVEL_DEFS],
|
||||
levels_fx: true,
|
||||
url_style: "default",
|
||||
mobile_tabs: [...DEFAULT_MOBILE_TABS],
|
||||
header_nav: DEFAULT_HEADER_NAV.map((item) => ({ ...item })),
|
||||
};
|
||||
|
||||
function normalizeAttachmentExts(raw: unknown): string[] {
|
||||
@@ -1167,6 +1242,9 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
||||
url_style: normalizeUrlStyle(data?.url_style),
|
||||
levels: normalizeLevels(data?.levels),
|
||||
levels_fx: data?.levels_fx !== false,
|
||||
mobile_tabs: normalizeMobileTabs(data?.mobile_tabs),
|
||||
// undefined(字段缺失)回落默认;[] 为空配置(全部隐藏),语义在 normalize 内区分
|
||||
header_nav: normalizeHeaderNav(data?.header_nav),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2686,6 +2764,19 @@ export async function apiTimelineFromGit(input: {
|
||||
return data as { items: TimelineGitItem[]; warning?: string; error?: string };
|
||||
}
|
||||
|
||||
export async function apiTimelineFromReleases(input: {
|
||||
urls: string[];
|
||||
}): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> {
|
||||
const res = await fetchWithRefresh("/api/timeline/from-releases", {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "导入失败");
|
||||
return data as { items: TimelineGitItem[]; warning?: string; error?: string };
|
||||
}
|
||||
|
||||
/** SSR / 公开:已发布单页目录 */
|
||||
export async function fetchPublishedSitePages(): Promise<{ pages: SitePageSummary[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/pages`, ssrInit());
|
||||
@@ -2753,6 +2844,215 @@ export async function apiAdminDeleteSitePage(id: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 彻底删除单页(仅限已软删;删除后 slug 释放可复用) */
|
||||
export async function apiAdminPurgeSitePage(id: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/pages/${id}/purge`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "彻底删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 书库(公开 + 管理) =====
|
||||
|
||||
/** SSR / 公开:已发布书库目录 */
|
||||
export async function fetchLibraryList(): Promise<{ docs: LibraryDocListItem[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/library`, ssrInit());
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取书库失败");
|
||||
return { docs: data.docs ?? [] };
|
||||
}
|
||||
|
||||
/** SSR / 公开:书库条目详情(404 返 null) */
|
||||
export async function fetchLibraryDocBySlug(slug: string): Promise<{ doc: LibraryDoc } | null> {
|
||||
const res = await fetch(`${API_BASE}/api/library/${encodeURIComponent(slug)}`, ssrInit());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error("获取条目失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** SSR:管理端书库目录(含未发布) */
|
||||
export async function fetchAdminLibraryDocs(
|
||||
cookieHeader?: string
|
||||
): Promise<{ docs: LibraryDoc[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/admin/library/docs`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取书库失败");
|
||||
return data as { docs: LibraryDoc[] };
|
||||
}
|
||||
|
||||
export async function apiAdminListLibraryDocs(): Promise<{ docs: LibraryDoc[] }> {
|
||||
const res = await fetchWithRefresh("/api/admin/library/docs", { headers: clientHeaders() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取书库失败");
|
||||
return data as { docs: LibraryDoc[] };
|
||||
}
|
||||
|
||||
async function saveLibraryDoc(
|
||||
method: "POST" | "PUT",
|
||||
url: string,
|
||||
input: LibraryInput
|
||||
): Promise<{ doc: LibraryDoc }> {
|
||||
const res = await fetchWithRefresh(url, {
|
||||
method,
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "保存条目失败");
|
||||
return data as { doc: LibraryDoc };
|
||||
}
|
||||
|
||||
export function apiAdminCreateLibraryDoc(input: LibraryInput) {
|
||||
return saveLibraryDoc("POST", "/api/admin/library/docs", input);
|
||||
}
|
||||
|
||||
export function apiAdminUpdateLibraryDoc(id: number, input: LibraryInput) {
|
||||
return saveLibraryDoc("PUT", `/api/admin/library/docs/${id}`, input);
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteLibraryDoc(id: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/docs/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "删除条目失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 彻底删除条目(仅限已软删;清除章节/文件行与磁盘文件,slug 释放可复用) */
|
||||
export async function apiAdminPurgeLibraryDoc(id: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/docs/${id}/purge`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "彻底删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传文件到书库条目(multipart;扩展名/大小由后端白名单校验) */
|
||||
export async function apiAdminUploadLibraryFile(
|
||||
docId: number,
|
||||
file: File
|
||||
): Promise<{ file: LibraryFileItem; error?: string }> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/files`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return { file: null as unknown as LibraryFileItem, error: data.error || "上传失败" };
|
||||
return data as { file: LibraryFileItem };
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteLibraryFile(fid: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/files/${fid}`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "删除文件失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 书库章节(两级树)=====
|
||||
|
||||
export interface LibrarySectionInput {
|
||||
parent_id?: number | null;
|
||||
title: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/** 批量导入的单个 md 文件 */
|
||||
export interface LibraryImportFileInput {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 导入 md 拆章入参:多文件按传入顺序依次拆章;split_level h1/h2/none 对每个文件生效 */
|
||||
export interface LibraryImportMdInput {
|
||||
files: LibraryImportFileInput[];
|
||||
split_level: "h1" | "h2" | "none";
|
||||
}
|
||||
|
||||
export async function apiAdminCreateLibrarySection(
|
||||
docId: number,
|
||||
input: LibrarySectionInput
|
||||
): Promise<{ section: LibrarySectionItem }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/sections`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "创建章节失败");
|
||||
return data as { section: LibrarySectionItem };
|
||||
}
|
||||
|
||||
export async function apiAdminUpdateLibrarySection(
|
||||
sid: number,
|
||||
input: Pick<LibrarySectionInput, "title" | "content">
|
||||
): Promise<{ section: LibrarySectionItem }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}`, {
|
||||
method: "PUT",
|
||||
headers: clientHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "保存章节失败");
|
||||
return data as { section: LibrarySectionItem };
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteLibrarySection(sid: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "删除章节失败");
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiAdminMoveLibrarySection(
|
||||
sid: number,
|
||||
direction: "up" | "down"
|
||||
): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}/move`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: JSON.stringify({ direction }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "移动章节失败");
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiAdminImportLibraryMarkdownBatch(
|
||||
docId: number,
|
||||
input: LibraryImportMdInput
|
||||
): Promise<{ created: number; sections: LibrarySectionItem[] }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/import-md-batch`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "导入失败");
|
||||
return data as { created: number; sections: LibrarySectionItem[] };
|
||||
}
|
||||
|
||||
// ===== 用户管理(管理员) =====
|
||||
|
||||
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
|
||||
@@ -3367,6 +3667,10 @@ export type UpdateSiteSettingsBody = {
|
||||
allow_register?: boolean;
|
||||
allow_comments?: boolean;
|
||||
url_style?: UrlStyle;
|
||||
/** 移动端底栏显示的 tab(固定顺序子集,整体替换保存;空数组会被后端拒绝) */
|
||||
mobile_tabs?: MobileTabKey[];
|
||||
/** 桌面端 Header 主导航(整体替换保存;空数组表示全部隐藏) */
|
||||
header_nav?: HeaderNavItem[];
|
||||
comments_require_login?: boolean;
|
||||
allow_messages?: boolean;
|
||||
post_cooldown_hours?: number;
|
||||
@@ -3484,6 +3788,20 @@ export async function apiBackgroundFromMedia(
|
||||
return url;
|
||||
}
|
||||
|
||||
/** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL(与原图解耦) */
|
||||
export async function apiLibraryCoverFromMedia(attachmentId: number): Promise<string> {
|
||||
const res = await fetchWithRefresh("/api/admin/library/cover/from-media", {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ attachment_id: attachmentId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
|
||||
const url = String(data.url || "");
|
||||
if (!url) throw new Error("选用媒体库图片失败");
|
||||
return url;
|
||||
}
|
||||
|
||||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||||
|
||||
export async function apiListBoards(): Promise<{ boards: Board[] }> {
|
||||
@@ -4443,3 +4761,101 @@ export async function apiAdminUpdateSidebarWidget(
|
||||
export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE");
|
||||
}
|
||||
|
||||
/* ---------- 友情链接(申请审批制) ---------- */
|
||||
|
||||
export type FriendLinkStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
export interface FriendLink {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
logo_url?: string;
|
||||
category?: string;
|
||||
reciprocal_url?: string;
|
||||
sort_order: number;
|
||||
status: FriendLinkStatus;
|
||||
applicant_user_id?: number | null;
|
||||
reject_reason?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FriendLinkApplyInput {
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
logo_url?: string;
|
||||
category?: string;
|
||||
reciprocal_url?: string;
|
||||
}
|
||||
|
||||
export interface FriendLinkAdminInput {
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
logo_url?: string;
|
||||
category?: string;
|
||||
reciprocal_url?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
/** SSR 公开友情链接列表(仅 approved) */
|
||||
export async function fetchFriendLinks(cookie?: string): Promise<{ links: FriendLink[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/friend-links`, ssrInit(cookieHeaders(cookie)));
|
||||
if (!res.ok) throw new Error("获取友情链接失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 用户提交友情链接申请(登录) */
|
||||
export async function apiApplyFriendLink(body: FriendLinkApplyInput): Promise<{ link: FriendLink }> {
|
||||
return apiOperations("/api/friend-links/apply", "POST", body);
|
||||
}
|
||||
|
||||
/** 我的申请记录(含待审/拒绝与拒绝理由) */
|
||||
export async function apiMyFriendLinks(): Promise<{ links: FriendLink[] }> {
|
||||
return apiOperations("/api/friend-links/mine");
|
||||
}
|
||||
|
||||
/** 申请人修改自己的申请;已通过/已驳回的修改后回退待审 */
|
||||
export async function apiUpdateMyFriendLink(
|
||||
id: number,
|
||||
body: FriendLinkApplyInput
|
||||
): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/friend-links/${id}`, "PUT", body);
|
||||
}
|
||||
|
||||
/** SSR 管理端全量列表(含 pending/rejected) */
|
||||
export async function fetchAdminFriendLinks(cookie?: string): Promise<{ links: FriendLink[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/admin/friend-links`, {
|
||||
...ssrInit(cookie ? { Cookie: cookie } : undefined),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("加载友情链接失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiAdminListFriendLinks(): Promise<{ links: FriendLink[] }> {
|
||||
return apiOperations("/api/admin/friend-links");
|
||||
}
|
||||
|
||||
export async function apiAdminCreateFriendLink(body: FriendLinkAdminInput): Promise<{ link: FriendLink }> {
|
||||
return apiOperations("/api/admin/friend-links", "POST", body);
|
||||
}
|
||||
|
||||
export async function apiAdminUpdateFriendLink(id: number, body: FriendLinkAdminInput): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/admin/friend-links/${id}`, "PUT", body);
|
||||
}
|
||||
|
||||
export async function apiAdminSetFriendLinkStatus(
|
||||
id: number,
|
||||
status: FriendLinkStatus,
|
||||
reject_reason?: string
|
||||
): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/admin/friend-links/${id}/status`, "POST", reject_reason ? { status, reject_reason } : { status });
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteFriendLink(id: number): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/admin/friend-links/${id}`, "DELETE");
|
||||
}
|
||||
|
||||
@@ -107,6 +107,14 @@ export function normalizeBrandURL(slot: BrandSlot, raw: unknown): string {
|
||||
return re.test(u) ? u : "";
|
||||
}
|
||||
|
||||
/** 品牌图固定对外地址(与 Go service/brand.go 的 BrandAliasPath* 对齐):
|
||||
* 友链等外部引用此地址,站点换图后地址不变 */
|
||||
export function brandAliasPath(slot: BrandSlot): string {
|
||||
if (slot === "logo_light") return "/api/brand/logo-light";
|
||||
if (slot === "logo_dark") return "/api/brand/logo-dark";
|
||||
return "/api/brand/favicon";
|
||||
}
|
||||
|
||||
export function normalizeKeywords(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
11
frontend/lib/coverColor.ts
Normal file
11
frontend/lib/coverColor.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 书名 → 稳定色相(0-359):djb2 哈希取模。
|
||||
* 无封面文本封面的配色来源——同一本书在任何页面、任何刷新下颜色一致,
|
||||
* 不使用 Math.random()(那会导致每次刷新变色)。
|
||||
* 用法:元素挂 .j13-text-cover 类并内联 --cover-h,取色规则见 globals.css。
|
||||
*/
|
||||
export function coverHue(title: string): number {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < title.length; i++) h = (h * 33) ^ title.charCodeAt(i);
|
||||
return (h >>> 0) % 360;
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
/** 统一拆分正文中的 [hide] / [timeline] 指令块(忽略代码围栏) */
|
||||
/** 统一拆分正文中的 [hide] / [timeline] / [entries] 指令块(忽略代码围栏) */
|
||||
|
||||
import {
|
||||
splitHideSegments,
|
||||
type HideKind,
|
||||
type HideSegment,
|
||||
} from "@/lib/hideBlocks";
|
||||
import { parseTimelineBody, type TimelineItem } from "@/lib/timelineBlocks";
|
||||
} from "./hideBlocks.ts";
|
||||
import { parseTimelineBody, type TimelineItem } from "./timelineBlocks.ts";
|
||||
import {
|
||||
isEntriesClose,
|
||||
isEntriesOffOpen,
|
||||
isEntriesOpen,
|
||||
parseEntriesBody,
|
||||
wrapImplicitEntries,
|
||||
type EntryItem,
|
||||
} from "./entryBlocks.ts";
|
||||
|
||||
export type ContentSegment =
|
||||
| { type: "md"; text: string }
|
||||
@@ -16,7 +24,8 @@ export type ContentSegment =
|
||||
locked: boolean;
|
||||
body: string;
|
||||
}
|
||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string };
|
||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
|
||||
| { type: "entries"; items: EntryItem[]; rawBody: string };
|
||||
|
||||
function isTimelineClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/timeline]";
|
||||
@@ -29,11 +38,23 @@ function isTimelineOpen(trimmed: string): boolean {
|
||||
/**
|
||||
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
|
||||
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
|
||||
* opts.entriesAuto:书级「全章节条目卡模式」开关——开启时按 wrapImplicitEntries
|
||||
* 把「## 标题 + @tags/@summary/@source/字段::值」写法包上 [entries] 壳再走统一拆分;
|
||||
* 关闭(含帖子/评论等非书场景)时均按普通 Markdown 渲染,条目卡只能用显式 [entries] 块。
|
||||
* [entries:off] 块内始终保持普通 Markdown。
|
||||
*/
|
||||
export function splitContentSegments(content: string): ContentSegment[] {
|
||||
export function splitContentSegments(
|
||||
content: string,
|
||||
opts?: { entriesAuto?: boolean }
|
||||
): ContentSegment[] {
|
||||
if (!content) return [{ type: "md", text: "" }];
|
||||
if (opts?.entriesAuto) {
|
||||
content = wrapImplicitEntries(content);
|
||||
}
|
||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const raw: Array<{ type: "md"; text: string } | { type: "timeline"; body: string }> = [];
|
||||
const raw: Array<
|
||||
{ type: "md"; text: string } | { type: "timeline"; body: string } | { type: "entries"; body: string }
|
||||
> = [];
|
||||
let buf: string[] = [];
|
||||
let inCode = false;
|
||||
let i = 0;
|
||||
@@ -44,6 +65,54 @@ export function splitContentSegments(content: string): ContentSegment[] {
|
||||
buf = [];
|
||||
};
|
||||
|
||||
/** 从 i 行的开标签扫到匹配闭标签;忽略代码围栏,嵌套开标签视为失败(返回 null,调用方按 md 处理) */
|
||||
const scanBlock = (
|
||||
isOpen: (t: string) => boolean,
|
||||
isClose: (t: string) => boolean
|
||||
): { body: string; end: number } | null => {
|
||||
const bodyLines: string[] = [];
|
||||
let j = i + 1;
|
||||
let innerCode = false;
|
||||
while (j < lines.length) {
|
||||
const inner = lines[j].trim();
|
||||
if (inner.startsWith("```")) {
|
||||
innerCode = !innerCode;
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (innerCode) {
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (isOpen(inner)) return null;
|
||||
if (isClose(inner)) return { body: bodyLines.join("\n"), end: j };
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 开闭标签块(timeline/entries)公共处理:失败按 md,成功推进 i */
|
||||
const takeBlock = (
|
||||
line: string,
|
||||
isOpen: (t: string) => boolean,
|
||||
isClose: (t: string) => boolean,
|
||||
type: "timeline" | "entries"
|
||||
): boolean => {
|
||||
const hit = scanBlock(isOpen, isClose);
|
||||
if (!hit) {
|
||||
buf.push(line);
|
||||
i++;
|
||||
return true;
|
||||
}
|
||||
flushMd();
|
||||
raw.push({ type, body: hit.body });
|
||||
i = hit.end + 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
@@ -59,45 +128,26 @@ export function splitContentSegments(content: string): ContentSegment[] {
|
||||
}
|
||||
|
||||
if (isTimelineOpen(trimmed)) {
|
||||
flushMd();
|
||||
const bodyLines: string[] = [];
|
||||
let j = i + 1;
|
||||
let innerCode = false;
|
||||
let found = false;
|
||||
while (j < lines.length) {
|
||||
const inner = lines[j].trim();
|
||||
if (inner.startsWith("```")) {
|
||||
innerCode = !innerCode;
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (innerCode) {
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (isTimelineOpen(inner)) {
|
||||
// 嵌套视为失败:整段当 md
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
if (isTimelineClose(inner)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (!found) {
|
||||
takeBlock(lines[i], isTimelineOpen, isTimelineClose, "timeline");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEntriesOpen(trimmed)) {
|
||||
takeBlock(lines[i], isEntriesOpen, isEntriesClose, "entries");
|
||||
continue;
|
||||
}
|
||||
|
||||
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
|
||||
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
|
||||
if (isEntriesOffOpen(trimmed)) {
|
||||
const hit = scanBlock(isEntriesOffOpen, isEntriesClose);
|
||||
if (!hit) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
raw.push({ type: "timeline", body: bodyLines.join("\n") });
|
||||
i = j + 1;
|
||||
buf.push(hit.body);
|
||||
i = hit.end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -116,6 +166,14 @@ export function splitContentSegments(content: string): ContentSegment[] {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (part.type === "entries") {
|
||||
out.push({
|
||||
type: "entries",
|
||||
items: parseEntriesBody(part.body),
|
||||
rawBody: part.body,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const hideSegs = splitHideSegments(part.text) as HideSegment[];
|
||||
for (const s of hideSegs) {
|
||||
if (s.type === "md") {
|
||||
|
||||
351
frontend/lib/entryBlocks.test.ts
Normal file
351
frontend/lib/entryBlocks.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
parseEntriesBody,
|
||||
parseEntrySource,
|
||||
serializeEntriesBlock,
|
||||
wrapImplicitEntries,
|
||||
} from "./entryBlocks.ts";
|
||||
import { splitContentSegments } from "./directiveBlocks.ts";
|
||||
import { buildTocFromSegments, extractToc } from "./toc.ts";
|
||||
|
||||
describe("parseEntrySource", () => {
|
||||
it("最后一个 | 后为 http(s) 时拆分文本与链接", () => {
|
||||
assert.deepEqual(parseEntrySource("NHTSA (2024). Report | https://a.gov/x"), {
|
||||
text: "NHTSA (2024). Report",
|
||||
url: "https://a.gov/x",
|
||||
});
|
||||
});
|
||||
it("无链接或非 http 后缀时整行作为文本", () => {
|
||||
assert.deepEqual(parseEntrySource("WHO 口径"), { text: "WHO 口径" });
|
||||
assert.deepEqual(parseEntrySource("标题 | 备注说明"), { text: "标题 | 备注说明" });
|
||||
});
|
||||
it("兼容全角竖线", () => {
|
||||
assert.deepEqual(parseEntrySource("来源|https://a.gov/x"), {
|
||||
text: "来源",
|
||||
url: "https://a.gov/x",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseEntriesBody", () => {
|
||||
it("完整条目:标题/标签/摘要/字段/来源", () => {
|
||||
const items = parseEntriesBody(
|
||||
[
|
||||
"## 系安全带,前排后排都系",
|
||||
"@tags 性价比:极高 | 证据:A级 | 不花钱",
|
||||
"@summary 坐前排系上安全带,概率大约降一半。",
|
||||
"成本 :: 不花钱。每次上车多花 2 秒。",
|
||||
"收益 :: NHTSA 估计致命伤风险下降 45%。",
|
||||
"@source NHTSA (2024). Report | https://a.gov/x",
|
||||
"@source WHO (2025). Fact sheet",
|
||||
].join("\n")
|
||||
);
|
||||
assert.equal(items.length, 1);
|
||||
assert.deepEqual(items[0], {
|
||||
title: "系安全带,前排后排都系",
|
||||
tags: ["性价比:极高", "证据:A级", "不花钱"],
|
||||
summary: "坐前排系上安全带,概率大约降一半。",
|
||||
fields: [
|
||||
{ label: "成本", value: "不花钱。每次上车多花 2 秒。" },
|
||||
{ label: "收益", value: "NHTSA 估计致命伤风险下降 45%。" },
|
||||
],
|
||||
sources: [
|
||||
{ text: "NHTSA (2024). Report", url: "https://a.gov/x" },
|
||||
{ text: "WHO (2025). Fact sheet" },
|
||||
],
|
||||
});
|
||||
});
|
||||
it("多条目按标题切分;标题后裸文本计入摘要", () => {
|
||||
const items = parseEntriesBody(
|
||||
[
|
||||
"## 甲",
|
||||
"第一段摘要。",
|
||||
"第二行摘要。",
|
||||
"成本 :: 10 元。",
|
||||
"## 乙",
|
||||
"@tags 快",
|
||||
].join("\n")
|
||||
);
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].title, "甲");
|
||||
assert.equal(items[0].summary, "第一段摘要。\n第二行摘要。");
|
||||
assert.deepEqual(items[0].fields, [{ label: "成本", value: "10 元。" }]);
|
||||
assert.equal(items[1].title, "乙");
|
||||
assert.deepEqual(items[1].tags, ["快"]);
|
||||
});
|
||||
it("字段值多行追加到最后一个字段;无条目的内容丢弃", () => {
|
||||
const items = parseEntriesBody(
|
||||
[
|
||||
"块首无标题行应丢弃",
|
||||
"## 甲",
|
||||
"成本 :: 第一行",
|
||||
"续行仍属成本",
|
||||
"@tags x",
|
||||
].join("\n")
|
||||
);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].fields[0].value, "第一行\n续行仍属成本");
|
||||
});
|
||||
it("空块返回空数组", () => {
|
||||
assert.deepEqual(parseEntriesBody(""), []);
|
||||
});
|
||||
it("@plain 标记无标题头,序列化往返保留", () => {
|
||||
const items = parseEntriesBody(["## 甲", "@plain", "@tags x", "成本 :: 1"].join("\n"));
|
||||
assert.equal(items[0].plain, true);
|
||||
const round = parseEntriesBody(
|
||||
serializeEntriesBlock(items).slice("[entries]\n".length, -"\n[/entries]".length)
|
||||
);
|
||||
assert.equal(round[0].plain, true);
|
||||
const normal = parseEntriesBody(["## 甲", "@tags x"].join("\n"));
|
||||
assert.equal(normal[0].plain, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeEntriesBlock", () => {
|
||||
it("往返:序列化后可重新解析", () => {
|
||||
const src = parseEntriesBody(
|
||||
[
|
||||
"## 系安全带",
|
||||
"@tags 性价比:极高 | 不花钱",
|
||||
"@summary 概率降一半。",
|
||||
"成本 :: 不花钱。",
|
||||
"@source NHTSA | https://a.gov/x",
|
||||
].join("\n")
|
||||
);
|
||||
const round = parseEntriesBody(serializeEntriesBlock(src).slice("[entries]\n".length, -"\n[/entries]".length));
|
||||
assert.deepEqual(round, src);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitContentSegments 集成 [entries]", () => {
|
||||
it("条目块单独成段,前后 md 保留", () => {
|
||||
const segs = splitContentSegments(
|
||||
["前文", "", "[entries]", "## 甲", "@tags x", "[/entries]", "", "后文"].join("\n")
|
||||
);
|
||||
assert.deepEqual(
|
||||
segs.map((s) => s.type),
|
||||
["md", "entries", "md"]
|
||||
);
|
||||
if (segs[1].type === "entries") {
|
||||
assert.equal(segs[1].items[0].title, "甲");
|
||||
} else {
|
||||
assert.fail("第二段应为 entries");
|
||||
}
|
||||
});
|
||||
it("代码围栏内的块标记与未闭合块按普通 md 处理", () => {
|
||||
const fenced = splitContentSegments(["```", "[entries]", "```"].join("\n"));
|
||||
assert.deepEqual(fenced.map((s) => s.type), ["md"]);
|
||||
const unclosed = splitContentSegments(["[entries]", "## 甲"].join("\n"));
|
||||
assert.deepEqual(unclosed.map((s) => s.type), ["md"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractToc 跳过 entries 块", () => {
|
||||
it("块内标题不进目录,块外锚点不错位", () => {
|
||||
const toc = extractToc(
|
||||
["## 外部一", "[entries]", "## 卡片标题", "[/entries]", "### 外部二"].join("\n")
|
||||
);
|
||||
assert.deepEqual(
|
||||
toc.map((t) => `${t.level}:${t.text}:${t.id}`),
|
||||
["2:外部一:h-0", "3:外部二:h-1"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapImplicitEntries(书级条目卡模式)", () => {
|
||||
it("标题 + 指令行自动包壳;普通小节与正文不受影响", () => {
|
||||
const src = [
|
||||
"# 第一章",
|
||||
"",
|
||||
"## 说明小节",
|
||||
"这是普通段落,不是条目。",
|
||||
"",
|
||||
"## 系安全带",
|
||||
"@tags 性价比:极高",
|
||||
"@summary 概率降一半。",
|
||||
"成本 :: 不花钱。",
|
||||
"",
|
||||
"普通正文段落,出现在条目之后。",
|
||||
].join("\n");
|
||||
const wrapped = wrapImplicitEntries(src);
|
||||
const segs = splitContentSegments(wrapped, { entriesAuto: false });
|
||||
assert.deepEqual(
|
||||
segs.map((s) => s.type),
|
||||
["md", "entries", "md"]
|
||||
);
|
||||
if (segs[1].type === "entries") {
|
||||
assert.equal(segs[1].items.length, 1);
|
||||
assert.equal(segs[1].items[0].title, "系安全带");
|
||||
assert.deepEqual(segs[1].items[0].tags, ["性价比:极高"]);
|
||||
assert.deepEqual(segs[1].items[0].fields, [{ label: "成本", value: "不花钱。" }]);
|
||||
} else {
|
||||
assert.fail("隐式条目应解析为 entries 段");
|
||||
}
|
||||
// 普通小节标题与条目后正文都按普通 md 渲染
|
||||
if (segs[0].type !== "md" || segs[2].type !== "md") assert.fail("前后应为 md 段");
|
||||
assert.ok(segs[0].text.includes("## 说明小节"));
|
||||
assert.ok(segs[2].text.includes("普通正文段落"));
|
||||
});
|
||||
|
||||
it("空行后仍是指令行时条目继续;空行后普通文本则条目收尾", () => {
|
||||
const src = [
|
||||
"## 甲",
|
||||
"@tags a",
|
||||
"",
|
||||
"备注 :: 续字段。",
|
||||
"",
|
||||
"收尾后的普通段落。",
|
||||
].join("\n");
|
||||
const items = parseEntriesBody(
|
||||
wrapImplicitEntries(src)
|
||||
.split("[entries]\n")[1]
|
||||
?.split("\n[/entries]")[0] ?? ""
|
||||
);
|
||||
assert.equal(items.length, 1);
|
||||
assert.deepEqual(items[0].tags, ["a"]);
|
||||
assert.deepEqual(items[0].fields, [{ label: "备注", value: "续字段。" }]);
|
||||
});
|
||||
|
||||
it("显式 [entries] 块与 [entries:off]、时间线块原样跳过", () => {
|
||||
const src = [
|
||||
"[entries]",
|
||||
"## 显式条目",
|
||||
"@tags x",
|
||||
"[/entries]",
|
||||
"",
|
||||
"[entries:off]",
|
||||
"## off 内标题",
|
||||
"@tags 不应识别为条目",
|
||||
"[/entries]",
|
||||
"",
|
||||
"## 隐式条目",
|
||||
"@summary 摘要。",
|
||||
].join("\n");
|
||||
const segs = splitContentSegments(src, { entriesAuto: true });
|
||||
assert.deepEqual(
|
||||
segs.map((s) => s.type),
|
||||
["entries", "md", "entries"]
|
||||
);
|
||||
if (segs[0].type === "entries") assert.equal(segs[0].items[0].title, "显式条目");
|
||||
else assert.fail("显式块应保持 entries");
|
||||
// off 段内容原样按普通 md 渲染(含 off 内字面标记行)
|
||||
if (segs[1].type !== "md") assert.fail("off 段应为 md");
|
||||
assert.ok(segs[1].text.includes("## off 内标题"));
|
||||
assert.ok(segs[1].text.includes("@tags 不应识别为条目"));
|
||||
if (segs[2].type === "entries") assert.equal(segs[2].items[0].title, "隐式条目");
|
||||
else assert.fail("off 之后的条目应正常识别");
|
||||
});
|
||||
|
||||
it("幂等:包壳结果重复执行不变", () => {
|
||||
const src = [
|
||||
"## 系安全带",
|
||||
"@tags 钱=0",
|
||||
"成本 :: 不花钱。",
|
||||
"",
|
||||
"## 隐式条目",
|
||||
"@tags x",
|
||||
].join("\n");
|
||||
const once = wrapImplicitEntries(src);
|
||||
assert.equal(wrapImplicitEntries(once), once);
|
||||
});
|
||||
|
||||
it("条目标题进卡后目录不再收录,块外标题锚点不漂移", () => {
|
||||
const src = [
|
||||
"## 前章",
|
||||
"",
|
||||
"### 系安全带",
|
||||
"@summary 降一半。",
|
||||
"成本 :: 不花钱。",
|
||||
"",
|
||||
"### 后节",
|
||||
].join("\n");
|
||||
const toc = extractToc(wrapImplicitEntries(src));
|
||||
assert.deepEqual(
|
||||
toc.map((t) => `${t.level}:${t.text}`),
|
||||
["2:前章", "3:后节"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTocFromSegments(条目卡模式目录)", () => {
|
||||
it("md 标题与条目标题按渲染顺序编入目录并分配锚点", () => {
|
||||
const src = [
|
||||
"开头说明。",
|
||||
"",
|
||||
"## 系安全带",
|
||||
"@tags 钱=0",
|
||||
"成本 :: 不花钱。",
|
||||
"",
|
||||
"### 小节标题",
|
||||
].join("\n");
|
||||
const segs = splitContentSegments(wrapImplicitEntries(src));
|
||||
let n = 0;
|
||||
const { items, mdHeadIds, entriesTitleIds } = buildTocFromSegments(segs, () => `e-1-${++n}`);
|
||||
assert.deepEqual(
|
||||
items.map((t) => `${t.level}:${t.text}`),
|
||||
["2:系安全带", "3:小节标题"]
|
||||
);
|
||||
assert.deepEqual(mdHeadIds, ["e-1-2"]);
|
||||
assert.equal(entriesTitleIds[0], undefined);
|
||||
assert.deepEqual(entriesTitleIds[1], ["e-1-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("注释标签写法不再识别(已收敛为一套 @ 指令写法)", () => {
|
||||
const USER_SAMPLE = [
|
||||
"<!-- 成本标签: 钱=0 时间=少 -->",
|
||||
"- 成本:不花钱。",
|
||||
"- 说人话:死亡风险降一半。",
|
||||
].join("\n");
|
||||
|
||||
it("entriesAuto 开启时注释标签 + 字段列表仍保持普通 Markdown", () => {
|
||||
const segs = splitContentSegments(USER_SAMPLE, { entriesAuto: true });
|
||||
assert.deepEqual(segs.map((s) => s.type), ["md"]);
|
||||
if (segs[0].type !== "md") assert.fail("应为 md 段");
|
||||
assert.ok(segs[0].text.includes("- 成本:不花钱。"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("端到端:@ 指令写法(安全带条目)", () => {
|
||||
it("标题/@tags/@summary/字段/多来源完整成卡", () => {
|
||||
const src = [
|
||||
"### 系安全带,前排后排都系",
|
||||
"@tags 钱=0 | 时间=少 | 毅力=否 | 收益=大 | 口径=死亡率 | 证据:A",
|
||||
"@summary 坐前排系上安全带,出车祸时被撞死的概率大约降一半。",
|
||||
"成本 :: 不花钱。每次上车多花 2 秒。",
|
||||
"收益 :: NHTSA 估计致命伤风险下降 45%。",
|
||||
"备注 :: 后排也要系。",
|
||||
"@source NHTSA (2024). Occupant Protection (DOT HS 813 573) | https://crashstats.nhtsa.dot.gov/Api/Public/ViewPublication/813573",
|
||||
"@source WHO (2025). Road traffic injuries fact sheet | https://www.who.int/news-room/fact-sheets/detail/road-traffic-injuries",
|
||||
].join("\n");
|
||||
const segs = splitContentSegments(src, { entriesAuto: true });
|
||||
assert.deepEqual(segs.map((s) => s.type), ["entries"]);
|
||||
const it = segs[0].items[0];
|
||||
assert.equal(it.title, "系安全带,前排后排都系");
|
||||
assert.equal(it.summary, "坐前排系上安全带,出车祸时被撞死的概率大约降一半。");
|
||||
assert.deepEqual(it.tags, [
|
||||
"钱=0",
|
||||
"时间=少",
|
||||
"毅力=否",
|
||||
"收益=大",
|
||||
"口径=死亡率",
|
||||
"证据:A",
|
||||
]);
|
||||
assert.deepEqual(it.fields, [
|
||||
{ label: "成本", value: "不花钱。每次上车多花 2 秒。" },
|
||||
{ label: "收益", value: "NHTSA 估计致命伤风险下降 45%。" },
|
||||
{ label: "备注", value: "后排也要系。" },
|
||||
]);
|
||||
assert.deepEqual(it.sources, [
|
||||
{
|
||||
text: "NHTSA (2024). Occupant Protection (DOT HS 813 573)",
|
||||
url: "https://crashstats.nhtsa.dot.gov/Api/Public/ViewPublication/813573",
|
||||
},
|
||||
{
|
||||
text: "WHO (2025). Road traffic injuries fact sheet",
|
||||
url: "https://www.who.int/news-room/fact-sheets/detail/road-traffic-injuries",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
298
frontend/lib/entryBlocks.ts
Normal file
298
frontend/lib/entryBlocks.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/** 正文 [entries]…[/entries] 条目卡块解析:
|
||||
* 通用「结构化小节」渲染约定——字段名不限定,任何书籍可定义自己的字段。
|
||||
* 块内语法:
|
||||
* ## 条目标题
|
||||
* @tags 标签一 | 主:次 | 标签三 → 标签 chips(| 或 | 分隔,"主:次" 渲染主次两段)
|
||||
* @summary 一句话结论 → 高亮摘要块(可多行;标题后的裸文本也计入摘要)
|
||||
* 字段名 :: 内容 → 灰色标签行(label 任意,可多行)
|
||||
* @source 来源文本 | https://… → 折叠来源列表(url 可省)
|
||||
*/
|
||||
|
||||
export interface EntrySource {
|
||||
text: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface EntryField {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EntryItem {
|
||||
title: string;
|
||||
tags: string[];
|
||||
summary?: string;
|
||||
fields: EntryField[];
|
||||
sources: EntrySource[];
|
||||
/** 无标题头(注释标签卡等场景):不渲染编号与标题行 */
|
||||
plain?: boolean;
|
||||
}
|
||||
|
||||
const HEADING_RE = /^#{1,4}\s+(.+)$/;
|
||||
/** 条目标题自带序号前缀("1."、"12、"、"(3)"、"(4)"):卡片不再叠加自动编号 */
|
||||
export const ENTRY_TITLE_NUM_RE = /^(?:[((]\d+[))]|\d+\s*[.、))])/;
|
||||
const TAGS_RE = /^@tags\s+(.+)$/i;
|
||||
const SUMMARY_RE = /^@summary(?:\s+(.*))?$/i;
|
||||
const SOURCE_RE = /^@source\s+(.+)$/i;
|
||||
const PLAIN_RE = /^@plain$/i;
|
||||
const FIELD_RE = /^([^:@\n]{1,40}?)\s*::\s*(.*)$/;
|
||||
|
||||
/** 来源行:"文本 | url",取最后一个 | 且后段为 http(s) 才拆分;文本/链接兼容全角竖线 */
|
||||
export function parseEntrySource(raw: string): EntrySource {
|
||||
const idx = Math.max(raw.lastIndexOf("|"), raw.lastIndexOf("|"));
|
||||
if (idx > 0) {
|
||||
const url = raw.slice(idx + 1).trim();
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
return { text: raw.slice(0, idx).trim(), url };
|
||||
}
|
||||
}
|
||||
return { text: raw.trim() };
|
||||
}
|
||||
|
||||
export function parseEntriesBody(body: string): EntryItem[] {
|
||||
const lines = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const items: EntryItem[] = [];
|
||||
let cur: EntryItem | null = null;
|
||||
/** 普通文本行的归宿:摘要 / 最后一个字段(缺省时并入摘要) */
|
||||
let target: { kind: "summary" } | { kind: "field"; index: number } | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (cur) items.push(cur);
|
||||
cur = null;
|
||||
target = null;
|
||||
};
|
||||
|
||||
const appendSummary = (text: string) => {
|
||||
if (!cur) return;
|
||||
cur.summary = cur.summary ? `${cur.summary}\n${text}` : text;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
|
||||
const hm = t.match(HEADING_RE);
|
||||
if (hm) {
|
||||
flush();
|
||||
cur = { title: hm[1].trim(), tags: [], fields: [], sources: [] };
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
if (!cur) continue;
|
||||
|
||||
const tm = t.match(TAGS_RE);
|
||||
if (tm) {
|
||||
cur.tags.push(...tm[1].split(/[||]/).map((s) => s.trim()).filter(Boolean));
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
const sm = t.match(SUMMARY_RE);
|
||||
if (sm) {
|
||||
const rest = (sm[1] ?? "").trim();
|
||||
if (rest) appendSummary(rest);
|
||||
target = { kind: "summary" };
|
||||
continue;
|
||||
}
|
||||
const scm = t.match(SOURCE_RE);
|
||||
if (scm) {
|
||||
cur.sources.push(parseEntrySource(scm[1]));
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
if (PLAIN_RE.test(t)) {
|
||||
cur.plain = true;
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
const fm = t.match(FIELD_RE);
|
||||
if (fm) {
|
||||
cur.fields.push({ label: fm[1].trim(), value: fm[2] });
|
||||
target = { kind: "field", index: cur.fields.length - 1 };
|
||||
continue;
|
||||
}
|
||||
// 普通行:追加到开放容器;无容器时视为摘要(标题后直接跟正文段落)
|
||||
if (target?.kind === "field") {
|
||||
const f = cur.fields[target.index];
|
||||
f.value = `${f.value}\n${t}`;
|
||||
} else {
|
||||
appendSummary(t);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 序列化回块文本(编辑器面板复用);selected=false 的条目丢弃,与时间线一致 */
|
||||
export function serializeEntriesBlock(items: (EntryItem & { selected?: boolean })[]): string {
|
||||
const parts = items
|
||||
.filter((it) => it.selected !== false)
|
||||
.map((it) => {
|
||||
const lines = [`## ${it.title.trim() || "未命名"}`];
|
||||
if (it.plain) lines.push("@plain");
|
||||
if (it.tags.length > 0) lines.push(`@tags ${it.tags.join(" | ")}`);
|
||||
if (it.summary?.trim()) lines.push(`@summary ${it.summary.trim()}`);
|
||||
for (const f of it.fields) {
|
||||
if (!f.label.trim() && !f.value.trim()) continue;
|
||||
lines.push(`${f.label.trim() || "备注"} :: ${f.value.trim()}`);
|
||||
}
|
||||
for (const s of it.sources) {
|
||||
if (!s.text.trim()) continue;
|
||||
lines.push(`@source ${s.text.trim()}${s.url?.trim() ? ` | ${s.url.trim()}` : ""}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
});
|
||||
return `[entries]\n${parts.join("\n\n")}\n[/entries]`;
|
||||
}
|
||||
|
||||
export const ENTRIES_OPEN = "[entries]";
|
||||
export const ENTRIES_CLOSE = "[/entries]";
|
||||
|
||||
/** [entries:off] 例外块:书级条目卡模式开启时,块内恢复普通 Markdown 渲染 */
|
||||
export const ENTRIES_OFF_OPEN = "[entries:off]";
|
||||
|
||||
export function isEntriesOpen(line: string): boolean {
|
||||
return line.trim().toLowerCase() === ENTRIES_OPEN;
|
||||
}
|
||||
|
||||
export function isEntriesClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === ENTRIES_CLOSE;
|
||||
}
|
||||
|
||||
export function isEntriesOffOpen(line: string): boolean {
|
||||
return line.trim().toLowerCase() === ENTRIES_OFF_OPEN;
|
||||
}
|
||||
|
||||
/** 条目指令行:@tags / @summary / @source / @plain / 字段名 :: 内容(隐式模式识别条目特征用) */
|
||||
export function isEntrySignalLine(t: string): boolean {
|
||||
return (
|
||||
TAGS_RE.test(t) || SUMMARY_RE.test(t) || SOURCE_RE.test(t) || PLAIN_RE.test(t) || FIELD_RE.test(t)
|
||||
);
|
||||
}
|
||||
|
||||
/** 已知指令块标记行(隐式模式扫描时原样跳过,不当作条目内容) */
|
||||
const DIRECTIVE_LINE_RE = /^\[(\/)?(entries(?::off)?|timeline|hide[^\]]*)\]$/i;
|
||||
|
||||
export function isDirectiveLine(t: string): boolean {
|
||||
return DIRECTIVE_LINE_RE.test(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐式条目模式:把「## 标题 + 条目指令行」的区间自动包上 [entries] 壳。
|
||||
* 规则:
|
||||
* - 标题行(1-4 级)与其后到空行/结构标记前的段落里出现任一条目指令行 → 确认为条目;
|
||||
* 否则视为普通小节标题,原样输出
|
||||
* - 条目体持续收集:指令行后的普通文本行归入条目(与显式块语义一致);
|
||||
* 空行后若仍是指令行则继续,否则条目在空行处收尾
|
||||
* - 代码围栏与已知指令块([entries]/[entries:off]/[timeline]/[hide…])原样跳过
|
||||
*/
|
||||
export function wrapImplicitEntries(src: string): string {
|
||||
const lines = src.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const out: string[] = [];
|
||||
let inCode = false;
|
||||
let i = 0;
|
||||
|
||||
/** 从 i 行的开标记找匹配闭标记,返回闭标记行下标;找不到返回 -1 */
|
||||
const findClose = (start: number, isClose: (t: string) => boolean): number => {
|
||||
let innerCode = false;
|
||||
for (let j = start; j < lines.length; j++) {
|
||||
const t = lines[j].trim();
|
||||
if (t.startsWith("```")) innerCode = !innerCode;
|
||||
if (!innerCode && isClose(t)) return j;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim();
|
||||
if (t.startsWith("```")) {
|
||||
inCode = !inCode;
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// 显式指令块整段原样([entries] 本身无需再包;off/timeline/hide 内容不参与隐式识别)
|
||||
if (isEntriesOpen(t) || isEntriesOffOpen(t) || isDirectiveLine(t)) {
|
||||
const close = findClose(
|
||||
i + 1,
|
||||
isEntriesOffOpen(t)
|
||||
? isEntriesClose
|
||||
: isEntriesOpen(t)
|
||||
? isEntriesClose
|
||||
: t.toLowerCase() === "[timeline]"
|
||||
? (x) => x.toLowerCase() === "[/timeline]"
|
||||
: (x) => x.toLowerCase().startsWith("[/hide")
|
||||
);
|
||||
if (close >= 0) {
|
||||
for (; i <= close; i++) out.push(lines[i]);
|
||||
} else {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (HEADING_RE.test(t)) {
|
||||
// 前导段:标题行后到空行/结构标记前的所有行
|
||||
const seg: string[] = [];
|
||||
let k = i + 1;
|
||||
while (k < lines.length) {
|
||||
const tk = lines[k].trim();
|
||||
if (!tk || tk.startsWith("```") || isDirectiveLine(tk) || HEADING_RE.test(tk)) break;
|
||||
seg.push(lines[k]);
|
||||
k++;
|
||||
}
|
||||
if (!seg.some(isEntrySignalLine)) {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// 条目确认:包壳输出标题 + 前导段,再继续吃条目体
|
||||
out.push(ENTRIES_OPEN);
|
||||
out.push(lines[i]);
|
||||
out.push(...seg);
|
||||
i = k;
|
||||
while (i < lines.length) {
|
||||
const t2 = lines[i].trim();
|
||||
if (!t2) {
|
||||
// 空行后仍是指令行 → 保留空行继续收集;否则条目到此收尾
|
||||
let p = i;
|
||||
while (p < lines.length && !lines[p].trim()) p++;
|
||||
if (p < lines.length && isEntrySignalLine(lines[p].trim())) {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (t2.startsWith("```") || isDirectiveLine(t2) || HEADING_RE.test(t2)) break;
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
out.push(ENTRIES_CLOSE);
|
||||
continue;
|
||||
}
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** 编辑器「条目卡」插入模板:字段名(字段名::内容)按书籍内容自定义 */
|
||||
export const ENTRIES_TEMPLATE = `[entries]
|
||||
## 条目标题
|
||||
@tags 标签 | 主:次
|
||||
@summary 一句话结论。
|
||||
字段名 :: 内容。
|
||||
@source 来源标题 | https://example.com
|
||||
[/entries]`;
|
||||
|
||||
/** 编辑器「条目卡」插入模板(隐式模式):书籍开启全章节条目卡后免写外壳 */
|
||||
export const ENTRIES_TEMPLATE_AUTO = `## 条目标题
|
||||
@tags 标签 | 主:次
|
||||
@summary 一句话结论。
|
||||
字段名 :: 内容。
|
||||
@source 来源标题 | https://example.com`;
|
||||
@@ -1,7 +0,0 @@
|
||||
// 友情链接(站点级静态配置)。无条目时侧栏不渲染该区块。
|
||||
export interface FriendLink {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const FRIEND_LINKS: FriendLink[] = [];
|
||||
78
frontend/lib/headerNav.test.ts
Normal file
78
frontend/lib/headerNav.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
DEFAULT_HEADER_NAV,
|
||||
HEADER_NAV_BUILTIN_KEYS,
|
||||
isHeaderNavCustom,
|
||||
normalizeHeaderNav,
|
||||
type HeaderNavItem,
|
||||
} from "./headerNav.ts";
|
||||
|
||||
describe("normalizeHeaderNav", () => {
|
||||
it("缺行/非法输入回落内置默认三项", () => {
|
||||
assert.deepEqual(normalizeHeaderNav(undefined), DEFAULT_HEADER_NAV);
|
||||
assert.deepEqual(normalizeHeaderNav(null), DEFAULT_HEADER_NAV);
|
||||
assert.deepEqual(normalizeHeaderNav("home"), DEFAULT_HEADER_NAV);
|
||||
});
|
||||
|
||||
it("默认三项顺序与注册表一致", () => {
|
||||
assert.equal(DEFAULT_HEADER_NAV.length, 3);
|
||||
assert.deepEqual(
|
||||
DEFAULT_HEADER_NAV.map((i) => (i.type === "builtin" ? i.key : "")),
|
||||
[...HEADER_NAV_BUILTIN_KEYS],
|
||||
);
|
||||
});
|
||||
|
||||
it("显式空数组表示全部隐藏,不回落默认", () => {
|
||||
assert.deepEqual(normalizeHeaderNav([]), []);
|
||||
});
|
||||
|
||||
it("自定义顺序与内容原样保留", () => {
|
||||
const items: HeaderNavItem[] = [
|
||||
{ type: "builtin", key: "library" },
|
||||
{ type: "custom", label: "关于", url: "/p/about", new_tab: false },
|
||||
{ type: "builtin", key: "home" },
|
||||
{ type: "custom", label: "官网", url: "https://example.com", new_tab: true },
|
||||
];
|
||||
assert.deepEqual(normalizeHeaderNav(items), items);
|
||||
});
|
||||
|
||||
it("内置 key 去空白/小写容错、重复去重", () => {
|
||||
assert.deepEqual(
|
||||
normalizeHeaderNav([
|
||||
{ type: "builtin", key: " HOME " },
|
||||
{ type: "builtin", key: "home" },
|
||||
{ type: "builtin", key: "library" },
|
||||
]),
|
||||
[
|
||||
{ type: "builtin", key: "home" },
|
||||
{ type: "builtin", key: "library" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("未知内置 key 与非法自定义项被剔除", () => {
|
||||
assert.deepEqual(
|
||||
normalizeHeaderNav([
|
||||
{ type: "builtin", key: "nope" },
|
||||
{ type: "custom", label: "", url: "/x" },
|
||||
{ type: "custom", label: "坏地址", url: "javascript:alert(1)" },
|
||||
{ type: "custom", label: "相对路径", url: "relative" },
|
||||
{ type: "weird", key: "home" },
|
||||
{ type: "custom", label: "合法", url: "/links" },
|
||||
]),
|
||||
[{ type: "custom", label: "合法", url: "/links", new_tab: false }],
|
||||
);
|
||||
});
|
||||
|
||||
it("自定义外链 new_tab 缺省为 true,站内为 false", () => {
|
||||
const got = normalizeHeaderNav([
|
||||
{ type: "custom", label: "外链", url: "https://example.com" },
|
||||
{ type: "custom", label: "站内", url: "/p/about" },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
assert.ok(got.every(isHeaderNavCustom));
|
||||
assert.equal((got[0] as { new_tab: boolean }).new_tab, true);
|
||||
assert.equal((got[1] as { new_tab: boolean }).new_tab, false);
|
||||
});
|
||||
});
|
||||
88
frontend/lib/headerNav.ts
Normal file
88
frontend/lib/headerNav.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 桌面端 Header 主导航配置的单一事实源:
|
||||
* 与后端 service.HeaderNavBuiltinKeys 保持一致;Header 渲染、后台管理面板、公共设置 normalize 共用。
|
||||
*
|
||||
* 配置为有序数组,数组顺序即展示顺序:
|
||||
* - builtin 只存 key,文案/图标/地址/高亮由前端注册表派生(地址随伪静态风格变化);
|
||||
* - custom 存文案 + 地址(站内路径或 http(s) 外链)。
|
||||
* 缺行/非法输入回落内置默认三项;显式空数组表示全部隐藏(前台不渲染导航)。
|
||||
* 后续新增内置页面:在本注册表与后端白名单各加一项,后台自动出现可选项。
|
||||
*/
|
||||
|
||||
import { footerURLAllowed, isExternalFooterURL, runeCount } from "./brand.ts";
|
||||
|
||||
export const HEADER_NAV_BUILTIN_KEYS = ["home", "leaderboard", "library"] as const;
|
||||
|
||||
export type HeaderNavBuiltinKey = (typeof HEADER_NAV_BUILTIN_KEYS)[number];
|
||||
|
||||
/** 内置菜单默认文案;后台面板用同一文案 */
|
||||
export const HEADER_NAV_BUILTIN_LABELS: Record<HeaderNavBuiltinKey, string> = {
|
||||
home: "首页",
|
||||
leaderboard: "排行榜",
|
||||
library: "书库",
|
||||
};
|
||||
|
||||
export type HeaderNavBuiltinItem = { type: "builtin"; key: HeaderNavBuiltinKey };
|
||||
export type HeaderNavCustomItem = {
|
||||
type: "custom";
|
||||
label: string;
|
||||
url: string;
|
||||
new_tab: boolean;
|
||||
};
|
||||
export type HeaderNavItem = HeaderNavBuiltinItem | HeaderNavCustomItem;
|
||||
|
||||
export const HEADER_NAV_LIMITS = {
|
||||
items: 8,
|
||||
label: 16,
|
||||
url: 200,
|
||||
} as const;
|
||||
|
||||
/** 内置默认三项(缺行时回落) */
|
||||
export const DEFAULT_HEADER_NAV: HeaderNavItem[] = HEADER_NAV_BUILTIN_KEYS.map((key) => ({
|
||||
type: "builtin" as const,
|
||||
key,
|
||||
}));
|
||||
|
||||
export function isHeaderNavCustom(item: HeaderNavItem): item is HeaderNavCustomItem {
|
||||
return item.type === "custom";
|
||||
}
|
||||
|
||||
/**
|
||||
* 宽容解析服务端配置:未知内置 key/重复内置/非法自定义项跳过;
|
||||
* 入参非数组回落默认;空数组原样返回(全部隐藏)。
|
||||
*/
|
||||
export function normalizeHeaderNav(raw: unknown): HeaderNavItem[] {
|
||||
if (!Array.isArray(raw)) return DEFAULT_HEADER_NAV.map((item) => ({ ...item }));
|
||||
const out: HeaderNavItem[] = [];
|
||||
const seenBuiltin = new Set<string>();
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const rec = entry as Record<string, unknown>;
|
||||
const type = String(rec.type ?? "").trim();
|
||||
if (type === "builtin") {
|
||||
const key = String(rec.key ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!(HEADER_NAV_BUILTIN_KEYS as readonly string[]).includes(key) || seenBuiltin.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenBuiltin.add(key);
|
||||
out.push({ type: "builtin", key: key as HeaderNavBuiltinKey });
|
||||
} else if (type === "custom") {
|
||||
const label = String(rec.label ?? "").trim();
|
||||
const url = String(rec.url ?? "").trim();
|
||||
if (!label || runeCount(label) > HEADER_NAV_LIMITS.label) continue;
|
||||
if (!footerURLAllowed(url)) continue;
|
||||
const omitted = rec.new_tab === undefined || rec.new_tab === null;
|
||||
const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false;
|
||||
out.push({ type: "custom", label, url, new_tab: newTab });
|
||||
}
|
||||
if (out.length >= HEADER_NAV_LIMITS.items) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 自定义菜单是否为外链(外链渲染 target=_blank) */
|
||||
export function isHeaderNavExternal(url: string): boolean {
|
||||
return isExternalFooterURL(url.trim());
|
||||
}
|
||||
39
frontend/lib/librarySections.ts
Normal file
39
frontend/lib/librarySections.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { LibrarySectionItem } from "@/lib/api";
|
||||
|
||||
/** 章 → 其下小节(保持平铺顺序;顺序沿用后端 sort_order) */
|
||||
export function groupSections(sections: LibrarySectionItem[]) {
|
||||
const groups: { chapter: LibrarySectionItem; subs: LibrarySectionItem[] }[] = [];
|
||||
for (const s of sections) {
|
||||
if (s.parent_id == null) {
|
||||
groups.push({ chapter: s, subs: [] });
|
||||
} else {
|
||||
groups[groups.length - 1]?.subs.push(s);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export interface ChapterNav {
|
||||
/** 当前章下标(0 起) */
|
||||
index: number;
|
||||
/** 章总数 */
|
||||
total: number;
|
||||
prev: LibrarySectionItem | null;
|
||||
next: LibrarySectionItem | null;
|
||||
}
|
||||
|
||||
/** 当前章在章节序列中的位置与相邻章(找不到回落第一章) */
|
||||
export function chapterNav(
|
||||
groups: { chapter: LibrarySectionItem }[],
|
||||
currentId: number | null
|
||||
): ChapterNav {
|
||||
const total = groups.length;
|
||||
let index = groups.findIndex((g) => g.chapter.id === currentId);
|
||||
if (index < 0) index = 0;
|
||||
return {
|
||||
index,
|
||||
total,
|
||||
prev: index > 0 ? groups[index - 1].chapter : null,
|
||||
next: index < total - 1 ? groups[index + 1].chapter : null,
|
||||
};
|
||||
}
|
||||
30
frontend/lib/mobileTabs.test.ts
Normal file
30
frontend/lib/mobileTabs.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { DEFAULT_MOBILE_TABS, MOBILE_TABS, normalizeMobileTabs } from "./mobileTabs.ts";
|
||||
|
||||
describe("normalizeMobileTabs", () => {
|
||||
it("合法子集原样保留", () => {
|
||||
assert.deepEqual(normalizeMobileTabs(["home", "me"]), ["home", "me"]);
|
||||
assert.deepEqual(normalizeMobileTabs(["messages"]), ["messages"]);
|
||||
});
|
||||
it("去重与去空白", () => {
|
||||
assert.deepEqual(normalizeMobileTabs(["home", "home", "", " ", "me"]), ["home", "me"]);
|
||||
});
|
||||
it("大小写与首尾空格容错", () => {
|
||||
assert.deepEqual(normalizeMobileTabs([" HOME ", "Me"]), ["home", "me"]);
|
||||
});
|
||||
it("非法元素被剔除", () => {
|
||||
assert.deepEqual(normalizeMobileTabs(["home", "trophy", 42, null]), ["home"]);
|
||||
});
|
||||
it("空数组/非法输入回落默认全量", () => {
|
||||
assert.deepEqual(normalizeMobileTabs([]), DEFAULT_MOBILE_TABS);
|
||||
assert.deepEqual(normalizeMobileTabs(["nope"]), DEFAULT_MOBILE_TABS);
|
||||
assert.deepEqual(normalizeMobileTabs(undefined), DEFAULT_MOBILE_TABS);
|
||||
assert.deepEqual(normalizeMobileTabs("home"), DEFAULT_MOBILE_TABS);
|
||||
assert.deepEqual(normalizeMobileTabs(null), DEFAULT_MOBILE_TABS);
|
||||
});
|
||||
it("默认全量与固定顺序一致", () => {
|
||||
assert.equal(DEFAULT_MOBILE_TABS.length, 5);
|
||||
assert.deepEqual(DEFAULT_MOBILE_TABS, [...MOBILE_TABS]);
|
||||
});
|
||||
});
|
||||
29
frontend/lib/mobileTabs.ts
Normal file
29
frontend/lib/mobileTabs.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 移动端底栏配置的单一事实源:
|
||||
* 与后端 service.MobileTabKeys 保持一致;管理面板、MobileTabBar、公共设置 normalize 共用。
|
||||
* 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认全量。
|
||||
*/
|
||||
|
||||
export const MOBILE_TABS = ["home", "library", "compose", "messages", "me"] as const;
|
||||
|
||||
export type MobileTabKey = (typeof MOBILE_TABS)[number];
|
||||
|
||||
/** 底栏默认全量配置 */
|
||||
export const DEFAULT_MOBILE_TABS: MobileTabKey[] = [...MOBILE_TABS];
|
||||
|
||||
/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认全量 */
|
||||
export function normalizeMobileTabs(raw: unknown): MobileTabKey[] {
|
||||
if (!Array.isArray(raw)) return [...DEFAULT_MOBILE_TABS];
|
||||
const seen = new Set<string>();
|
||||
const out: MobileTabKey[] = [];
|
||||
for (const item of raw) {
|
||||
const key = String(item ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!key || seen.has(key)) continue;
|
||||
if (!(MOBILE_TABS as readonly string[]).includes(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(key as MobileTabKey);
|
||||
}
|
||||
return out.length > 0 ? out : [...DEFAULT_MOBILE_TABS];
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
// - HTTP 仅作回前台 / 进页校准;实时靠 WS(通知、待审、主题、群聊未读)。
|
||||
|
||||
import { apiMe } from "./api";
|
||||
import type { HeaderNavItem } from "./headerNav";
|
||||
|
||||
export const RT_HELLO = "hello";
|
||||
export const RT_PONG = "pong";
|
||||
@@ -62,6 +63,8 @@ export interface RtSettingsData {
|
||||
allow_register?: boolean;
|
||||
allow_comments?: boolean;
|
||||
url_style?: string;
|
||||
mobile_tabs?: string[];
|
||||
header_nav?: HeaderNavItem[];
|
||||
comments_require_login?: boolean;
|
||||
allow_messages?: boolean;
|
||||
post_cooldown_hours?: number;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
canonicalPath,
|
||||
homeCanonicalQuery,
|
||||
isTrackingParam,
|
||||
isWideShellPath,
|
||||
parseSiteOrigin,
|
||||
resolveSiteOrigin,
|
||||
} from "./siteUrl.ts";
|
||||
@@ -115,3 +116,24 @@ describe("homeCanonicalQuery", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWideShellPath(书库页放宽档位)", () => {
|
||||
it("覆盖三种伪静态风格的书库路径", () => {
|
||||
assert.equal(isWideShellPath("/library"), true);
|
||||
assert.equal(isWideShellPath("/library/abc"), true);
|
||||
assert.equal(isWideShellPath("/library/abc/read"), true);
|
||||
assert.equal(isWideShellPath("/library.html"), true);
|
||||
assert.equal(isWideShellPath("/library-abc.html"), true);
|
||||
assert.equal(isWideShellPath("/library-abc.html?sec=2"), true);
|
||||
assert.equal(isWideShellPath("/library-abc-read-2.html"), true);
|
||||
});
|
||||
it("非书库路径不放宽", () => {
|
||||
assert.equal(isWideShellPath("/"), false);
|
||||
assert.equal(isWideShellPath("/post-1.html"), false);
|
||||
assert.equal(isWideShellPath("/p-about.html"), false);
|
||||
assert.equal(isWideShellPath("/u/5"), false);
|
||||
assert.equal(isWideShellPath("/admin/library"), false);
|
||||
assert.equal(isWideShellPath("/my-library.html"), false);
|
||||
assert.equal(isWideShellPath(null), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,21 @@ export function canonicalBoard(raw: string | undefined): number | undefined {
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 书库页路径:命中时公开站三件套(Header/main/Footer)放宽到 1680px,
|
||||
* 读页三栏与条目卡等内容更宽裕;其余页面保持 1440px。
|
||||
* 需覆盖三种伪静态风格:/library[/...](default)、/library.html 与 /library-{slug}.html(html/discuz,
|
||||
* 新章节地址 = 详情路径 + ?sec=,pathname 与详情一致) */
|
||||
export function isWideShellPath(pathname: string | null | undefined): boolean {
|
||||
if (!pathname) return false;
|
||||
const p = pathname.split("?")[0];
|
||||
return (
|
||||
p === "/library" ||
|
||||
p.startsWith("/library/") ||
|
||||
p === "/library.html" ||
|
||||
/^\/library-.*\.html$/.test(p)
|
||||
);
|
||||
}
|
||||
|
||||
export function defaultFeedSort(allowComments: boolean): FeedSort {
|
||||
return allowComments ? "latest" : "new";
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ function isDirectiveLine(t: string): boolean {
|
||||
return (
|
||||
lower === "[/hide]" ||
|
||||
lower === "[/timeline]" ||
|
||||
lower === "[/entries]" ||
|
||||
lower === "[timeline]" ||
|
||||
lower === "[entries]" ||
|
||||
lower.startsWith("[hide") ||
|
||||
t.startsWith("<!-- git:")
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TocItem } from "@/components/PostToc";
|
||||
import type { ContentSegment } from "@/lib/directiveBlocks";
|
||||
|
||||
const TIMELINE_DAY_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/;
|
||||
const HEADING_RE = /^(#{2,3})\s+(.+?)\s*#*\s*$/;
|
||||
@@ -14,13 +15,14 @@ export function timelineDayId(date: string): string {
|
||||
* 从正文提取目录:
|
||||
* - 块外 ## / ### → h-{n}(供 headingRenderer 按序挂 id)
|
||||
* - [timeline] 内按日期去重 → tl-YYYY-MM-DD(日分组锚点)
|
||||
* - 代码围栏 / [hide] 块内标题不计入
|
||||
* - 代码围栏 / [hide] / [entries] 块内标题不计入
|
||||
*/
|
||||
export function extractToc(content: string): TocItem[] {
|
||||
const items: TocItem[] = [];
|
||||
let inCode = false;
|
||||
let inTimeline = false;
|
||||
let inHide = false;
|
||||
let inEntries = false;
|
||||
let headingCount = 0;
|
||||
/** 当前 timeline 块内出现过的日期(保持文件顺序,收块时再按日倒序发出) */
|
||||
let timelineDates: string[] = [];
|
||||
@@ -65,6 +67,17 @@ export function extractToc(content: string): TocItem[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inHide && !inTimeline && !inEntries && lower === "[entries]") {
|
||||
inEntries = true;
|
||||
continue;
|
||||
}
|
||||
if (inEntries && lower === "[/entries]") {
|
||||
inEntries = false;
|
||||
continue;
|
||||
}
|
||||
// 条目卡块内标题不进目录(渲染时也不挂 h-{n} 锚点,避免错位)
|
||||
if (inEntries) continue;
|
||||
|
||||
if (!inHide && HIDE_OPEN_RE.test(line)) {
|
||||
inHide = true;
|
||||
continue;
|
||||
@@ -95,6 +108,60 @@ export function markdownHeadingToc(toc: TocItem[]): TocItem[] {
|
||||
return toc.filter((t) => /^h-\d+$/.test(t.id));
|
||||
}
|
||||
|
||||
/** buildTocFromSegments 的产物:目录项 + 供正文挂锚点的三组 id */
|
||||
export interface SegmentsToc {
|
||||
/** 按渲染顺序的目录项(md 标题 / 条目标题 / 时间线日),id 已由 nextAnchor 分配 */
|
||||
items: TocItem[];
|
||||
/** md 段标题 id 序列(供 headingComponentsFor 按序挂到 h2/h3) */
|
||||
mdHeadIds: string[];
|
||||
/** 按段索引组织的条目标题 id(传给 MdEntries 挂到卡内标题) */
|
||||
entriesTitleIds: (string[] | undefined)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于「渲染分段」构建章内目录(条目卡模式下的目录来源):
|
||||
* - md 段标题 → h2/h3 级目录项(顺序与 headingRenderer 一致)
|
||||
* - entries 段条目标题 → 二级目录项(与卡内标题共享锚点,目录可跳转)
|
||||
* - timeline 段 → 日分组锚点(与 extractToc 同为日倒序);hide 段内容不进目录
|
||||
* nextAnchor 与阅读页章内锚点计数器共享,保证目录、正文、卡内标题同一份 id
|
||||
*/
|
||||
export function buildTocFromSegments(
|
||||
segs: ContentSegment[],
|
||||
nextAnchor: () => string
|
||||
): SegmentsToc {
|
||||
const items: TocItem[] = [];
|
||||
const mdHeadIds: string[] = [];
|
||||
const entriesTitleIds: (string[] | undefined)[] = [];
|
||||
|
||||
for (let si = 0; si < segs.length; si++) {
|
||||
const seg = segs[si];
|
||||
if (seg.type === "md") {
|
||||
for (const raw of seg.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
|
||||
const m = HEADING_RE.exec(raw.trim());
|
||||
if (!m) continue;
|
||||
const id = nextAnchor();
|
||||
items.push({ level: m[1].length as 2 | 3, text: m[2], id });
|
||||
mdHeadIds.push(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (seg.type === "entries") {
|
||||
const ids = seg.items.map(() => nextAnchor());
|
||||
seg.items.forEach((it, i) => items.push({ level: 2, text: it.title, id: ids[i] }));
|
||||
entriesTitleIds[si] = ids;
|
||||
continue;
|
||||
}
|
||||
if (seg.type === "timeline") {
|
||||
const dates = [...new Set(seg.items.map((t) => t.date))].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
||||
for (const date of dates) {
|
||||
items.push({ level: 2, text: date, id: timelineDayId(date) });
|
||||
}
|
||||
}
|
||||
// hide 段内容(未解锁/未登录不可见)不进目录
|
||||
}
|
||||
return { items, mdHeadIds, entriesTitleIds };
|
||||
}
|
||||
|
||||
/** 粗略阅读时长:约 400 字/分钟,至少 1 分钟 */
|
||||
export function estimateReadingMinutes(text: string, fallbackLen = 0): number {
|
||||
const len = text.trim().length > 0 ? text.trim().length : fallbackLen;
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
announcementPath,
|
||||
entityPath,
|
||||
leaderboardPath,
|
||||
libraryDocPath,
|
||||
libraryDocReadPath,
|
||||
libraryPath,
|
||||
matchPrettyPath,
|
||||
matchStandardPath,
|
||||
normalizeUrlStyle,
|
||||
@@ -57,6 +60,29 @@ describe("路径构造器", () => {
|
||||
assert.equal(leaderboardPath("html"), "/leaderboard.html");
|
||||
assert.equal(leaderboardPath("discuz"), "/rank.html");
|
||||
});
|
||||
it("书库三风格(html/discuz 同模板;章节 = 详情路径 + ?sec=)", () => {
|
||||
assert.equal(libraryPath("default"), "/library");
|
||||
assert.equal(libraryPath("html"), "/library.html");
|
||||
assert.equal(libraryPath("discuz"), "/library.html");
|
||||
assert.equal(libraryDocPath("default", "abc"), "/library/abc");
|
||||
assert.equal(libraryDocPath("html", "abc"), "/library-abc.html");
|
||||
assert.equal(libraryDocPath("discuz", "abc"), "/library-abc.html");
|
||||
assert.equal(
|
||||
libraryDocPath("html", "使用指南"),
|
||||
`/library-${encodeURIComponent("使用指南")}.html`
|
||||
);
|
||||
assert.equal(libraryDocPath("default", " "), "/library");
|
||||
assert.equal(libraryDocPath("html", " "), "/library.html");
|
||||
// 阅读页:chapterNo 为书内章序号(1 起),缺省表示第一章
|
||||
assert.equal(libraryDocReadPath("default", "abc"), "/library/abc/read");
|
||||
assert.equal(libraryDocReadPath("default", "abc", 5), "/library/abc/read?c=5");
|
||||
assert.equal(libraryDocReadPath("html", "abc"), "/library-abc.html?sec=1");
|
||||
assert.equal(libraryDocReadPath("discuz", "abc", 5), "/library-abc.html?sec=5");
|
||||
assert.equal(
|
||||
libraryDocReadPath("html", "使用指南", 2),
|
||||
`/library-${encodeURIComponent("使用指南")}.html?sec=2`
|
||||
);
|
||||
});
|
||||
it("entityPath 与标准路径", () => {
|
||||
assert.equal(entityPath("discuz", "post", 9), "/thread-9.html");
|
||||
assert.equal(entityPath("default", "user", 9), "/u/9");
|
||||
@@ -76,6 +102,41 @@ describe("matchPrettyPath", () => {
|
||||
assert.deepEqual(matchPrettyPath("/leaderboard.html"), { style: "html", kind: "leaderboard" });
|
||||
assert.deepEqual(matchPrettyPath("/rank.html"), { style: "discuz", kind: "leaderboard" });
|
||||
});
|
||||
it("书库两风格同模板:prefer 优先当前风格,缺省首条兜底", () => {
|
||||
assert.equal(matchPrettyPath("/library.html", "html")?.style, "html");
|
||||
assert.equal(matchPrettyPath("/library.html", "discuz")?.style, "discuz");
|
||||
assert.equal(matchPrettyPath("/library.html")?.style, "html");
|
||||
assert.deepEqual(matchPrettyPath("/library-x.html", "discuz"), {
|
||||
style: "discuz",
|
||||
kind: "libraryDoc",
|
||||
slug: "x",
|
||||
});
|
||||
assert.deepEqual(matchPrettyPath("/library-x.html"), { style: "html", kind: "libraryDoc", slug: "x" });
|
||||
});
|
||||
it("旧版章节伪静态 -read-{n} 仍可解析(不再生成)", () => {
|
||||
assert.deepEqual(matchPrettyPath("/library-x-read-2.html", "discuz"), {
|
||||
style: "discuz",
|
||||
kind: "libraryRead",
|
||||
slug: "x",
|
||||
id: 2,
|
||||
});
|
||||
assert.deepEqual(matchPrettyPath("/library-x-read-2.html"), {
|
||||
style: "html",
|
||||
kind: "libraryRead",
|
||||
slug: "x",
|
||||
id: 2,
|
||||
});
|
||||
// 详情模板不再吞掉 -read-{n};纯详情仍命中 libraryDoc
|
||||
assert.deepEqual(matchPrettyPath("/library-x.html"), { style: "html", kind: "libraryDoc", slug: "x" });
|
||||
assert.equal(matchPrettyPath("/library-x-read-2.htmlx"), null);
|
||||
assert.equal(matchPrettyPath("/library-x-read-2.html/edit"), null);
|
||||
});
|
||||
it("书库 pretty 排除子路径与伪造路径", () => {
|
||||
assert.equal(matchPrettyPath("/library-x.htmlx"), null);
|
||||
assert.equal(matchPrettyPath("/library-x.html/edit"), null);
|
||||
assert.equal(matchPrettyPath("/library-.html"), null);
|
||||
assert.equal(matchPrettyPath("/library.html/x"), null);
|
||||
});
|
||||
it("page slug 保持 encoded 原样", () => {
|
||||
const enc = encodeURIComponent("使用指南");
|
||||
assert.deepEqual(matchPrettyPath(`/page-${enc}.html`), { style: "discuz", kind: "page", slug: enc });
|
||||
@@ -104,11 +165,18 @@ describe("matchStandardPath / parseEntityPath", () => {
|
||||
assert.deepEqual(matchStandardPath("/p/about"), { kind: "page", slug: "about" });
|
||||
assert.deepEqual(matchStandardPath("/leaderboard"), { kind: "leaderboard" });
|
||||
assert.deepEqual(matchStandardPath("/leaderboard/"), { kind: "leaderboard" });
|
||||
assert.deepEqual(matchStandardPath("/library"), { kind: "library" });
|
||||
assert.deepEqual(matchStandardPath("/library/"), { kind: "library" });
|
||||
assert.deepEqual(matchStandardPath("/library/abc"), { kind: "libraryDoc", slug: "abc" });
|
||||
assert.equal(matchStandardPath("/post/12/edit"), null);
|
||||
assert.equal(matchStandardPath("/post/abc"), null);
|
||||
assert.equal(matchStandardPath("/u/5/followers"), null);
|
||||
assert.equal(matchStandardPath("/p/"), null);
|
||||
assert.equal(matchStandardPath("/p/a/b"), null);
|
||||
// 读页单独识别(?c= 走 query),不得被详情模式误伤
|
||||
assert.deepEqual(matchStandardPath("/library/abc/read"), { kind: "libraryRead", slug: "abc" });
|
||||
assert.equal(matchStandardPath("/library/a/b"), null);
|
||||
assert.equal(matchStandardPath("/libraryx"), null);
|
||||
});
|
||||
it("parseEntityPath 兼容标准与全部模板", () => {
|
||||
assert.deepEqual(parseEntityPath("/post/12"), { kind: "post", id: 12 });
|
||||
@@ -125,4 +193,12 @@ describe("matchStandardPath / parseEntityPath", () => {
|
||||
assert.equal(parseEntityPath("/chat/5"), null);
|
||||
assert.equal(parseEntityPath("/"), null);
|
||||
});
|
||||
it("parseEntityPath 兼容书库路径并解码 slug", () => {
|
||||
const enc = encodeURIComponent("三体");
|
||||
assert.deepEqual(parseEntityPath(`/library/${enc}`), { kind: "libraryDoc", slug: "三体" });
|
||||
assert.deepEqual(parseEntityPath(`/library-${enc}.html`), { kind: "libraryDoc", slug: "三体" });
|
||||
assert.deepEqual(parseEntityPath("/library.html"), { kind: "library" });
|
||||
assert.deepEqual(parseEntityPath("/library/abc/read"), { kind: "libraryRead", slug: "abc" });
|
||||
assert.deepEqual(parseEntityPath("/library-abc-read-2.html"), { kind: "libraryRead", slug: "abc" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
* middleware(改写/301)、URL 构造器、后台设置、测试共用本模块。
|
||||
* 风格枚举与后端 service.UrlStyles 保持一致:default / html / discuz。
|
||||
*
|
||||
* 各风格模板(自建页 slug 与排行榜也参与;slug 非 ASCII 时按 encodeURIComponent 编码):
|
||||
* default: /post/123 /u/5 /announcement/1 /p/about /leaderboard
|
||||
* html: /post-123.html /user-5.html /announcement-1.html /p-about.html /leaderboard.html
|
||||
* discuz: /thread-123.html /space-uid-5.html /notice-1.html /page-about.html /rank.html
|
||||
* 各风格模板(自建页 slug、排行榜与书库也参与;slug 非 ASCII 时按 encodeURIComponent 编码):
|
||||
* default: /post/123 /u/5 /announcement/1 /p/about /leaderboard /library /library/{slug} /library/{slug}/read?c={书内章号}
|
||||
* html: /post-123.html /user-5.html /announcement-1.html /p-about.html /leaderboard.html /library.html /library-{slug}.html (章节:/library-{slug}.html?sec={书内章号})
|
||||
* discuz: /thread-123.html /space-uid-5.html /notice-1.html /page-about.html /rank.html (书库两风格同 html 模板)
|
||||
* 书库章节走详情路径 + ?sec= 查询参数(章内子目录锚点 #e-{sec}-{章内序号});旧版 -read-{n} 路径
|
||||
* 不再生成,但正则保留可解析(middleware 改写回标准读页),旧链接不失效。
|
||||
*/
|
||||
|
||||
export type UrlStyle = "default" | "html" | "discuz";
|
||||
export type EntityKind = "post" | "user" | "announcement";
|
||||
/** 全部可反解的实体类型;page 的 id 是 slug(字符串),leaderboard 是固定单页无 id */
|
||||
export type PageKind = EntityKind | "page" | "leaderboard";
|
||||
/** 全部可反解的实体类型;page/libraryDoc/libraryRead 的 id 是 slug(字符串),leaderboard/library 是固定单页无 id */
|
||||
export type PageKind = EntityKind | "page" | "leaderboard" | "library" | "libraryDoc" | "libraryRead";
|
||||
|
||||
export const URL_STYLES: readonly UrlStyle[] = ["default", "html", "discuz"];
|
||||
|
||||
@@ -50,6 +52,18 @@ const LEADERBOARD_TEMPLATES: Record<Exclude<UrlStyle, "default">, string> = {
|
||||
discuz: "/rank.html",
|
||||
};
|
||||
|
||||
/** 书库伪静态模板:两风格统一(书库非 Discuz 原生概念);章节在详情路径上追加 ?sec= */
|
||||
const LIBRARY_TEMPLATES: Record<Exclude<UrlStyle, "default">, { list: string; doc: (slug: string) => string }> = {
|
||||
html: {
|
||||
list: "/library.html",
|
||||
doc: (slug) => `/library-${slug}.html`,
|
||||
},
|
||||
discuz: {
|
||||
list: "/library.html",
|
||||
doc: (slug) => `/library-${slug}.html`,
|
||||
},
|
||||
};
|
||||
|
||||
/** id 非法(≤0/非有限数)时返回空串,调用方按异常路径处理 */
|
||||
function guardId(id: number): number | null {
|
||||
return Number.isFinite(id) && id > 0 ? id : null;
|
||||
@@ -97,6 +111,40 @@ export function leaderboardPath(style: UrlStyle): string {
|
||||
return style === "default" ? "/leaderboard" : LEADERBOARD_TEMPLATES[style];
|
||||
}
|
||||
|
||||
/** 书库列表路径:default 标准路径,html/discuz 伪静态(两风格统一模板) */
|
||||
export function libraryPath(style: UrlStyle): string {
|
||||
return style === "default" ? "/library" : LIBRARY_TEMPLATES[style].list;
|
||||
}
|
||||
|
||||
/** 书库条目详情路径 */
|
||||
export function libraryDocPath(style: UrlStyle, slug: string): string {
|
||||
return libraryDocPathFromSlug(style, encodeURIComponent(slug.trim()));
|
||||
}
|
||||
|
||||
/** 书库条目详情路径(slug 已是 URL 编码形态);middleware 301 复用,避免二次编码 */
|
||||
export function libraryDocPathFromSlug(style: UrlStyle, encodedSlug: string): string {
|
||||
if (style === "default") {
|
||||
return encodedSlug ? `/library/${encodedSlug}` : "/library";
|
||||
}
|
||||
return encodedSlug ? LIBRARY_TEMPLATES[style].doc(encodedSlug) : libraryPath(style);
|
||||
}
|
||||
|
||||
/** 书库阅读页路径。
|
||||
* chapterNo 为书内章序号(1 起,非章节全局 id):
|
||||
* default → /library/{slug}/read?c={n};html/discuz → /library-{slug}.html?sec={n}(缺省第一章)。
|
||||
* 章内子目录锚点为 #e-{sec}-{章内序号}(见读页),如 ?sec=1#e-1-1 */
|
||||
export function libraryDocReadPath(style: UrlStyle, slug: string, chapterNo?: number): string {
|
||||
const enc = encodeURIComponent(slug.trim());
|
||||
if (style !== "default") {
|
||||
if (!enc) return libraryPath(style);
|
||||
const no = guardId(chapterNo ?? 0) ?? 1;
|
||||
return `${LIBRARY_TEMPLATES[style].doc(enc)}?sec=${no}`;
|
||||
}
|
||||
const base = enc ? `/library/${enc}/read` : "/library";
|
||||
const no = guardId(chapterNo ?? 0);
|
||||
return no ? `${base}?c=${no}` : base;
|
||||
}
|
||||
|
||||
/** 实体对应风格下的标准/pretty 路径(内部与 middleware 共用) */
|
||||
export function entityPath(style: UrlStyle, kind: EntityKind, id: number): string {
|
||||
if (kind === "post") return postPath(style, id);
|
||||
@@ -112,21 +160,32 @@ export function standardEntityPath(kind: EntityKind, id: number): string {
|
||||
export type PrettyMatch =
|
||||
| { style: Exclude<UrlStyle, "default">; kind: EntityKind; id: number }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "page"; slug: string }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "libraryDoc"; slug: string }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "libraryRead"; slug: string; id: number }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "library" }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "leaderboard" };
|
||||
|
||||
// 精确锚定:id 仅纯数字,天然排除 /post/123/edit 这类子路径与伪造路径;
|
||||
// page 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用
|
||||
// page/libraryDoc 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用。
|
||||
// 书库两风格同模板:html 条目在前作首条兜底,middleware 按 prefer 优先取当前风格;
|
||||
// 旧版章节模板 -read-{n} 仍须排在详情模板之前(否则会被 libraryDoc 的 [^/]+ 吞掉 -read-2),仅用于旧链接解析
|
||||
const PRETTY_PATTERNS: { re: RegExp; style: Exclude<UrlStyle, "default">; kind: PageKind }[] = [
|
||||
{ re: /^\/post-(\d+)\.html$/, style: "html", kind: "post" },
|
||||
{ re: /^\/user-(\d+)\.html$/, style: "html", kind: "user" },
|
||||
{ re: /^\/announcement-(\d+)\.html$/, style: "html", kind: "announcement" },
|
||||
{ re: /^\/p-([^/]+)\.html$/, style: "html", kind: "page" },
|
||||
{ re: /^\/leaderboard\.html$/, style: "html", kind: "leaderboard" },
|
||||
{ re: /^\/library\.html$/, style: "html", kind: "library" },
|
||||
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "html", kind: "libraryRead" },
|
||||
{ re: /^\/library-([^/]+)\.html$/, style: "html", kind: "libraryDoc" },
|
||||
{ re: /^\/thread-(\d+)\.html$/, style: "discuz", kind: "post" },
|
||||
{ re: /^\/space-uid-(\d+)\.html$/, style: "discuz", kind: "user" },
|
||||
{ re: /^\/notice-(\d+)\.html$/, style: "discuz", kind: "announcement" },
|
||||
{ re: /^\/page-([^/]+)\.html$/, style: "discuz", kind: "page" },
|
||||
{ re: /^\/rank\.html$/, style: "discuz", kind: "leaderboard" },
|
||||
{ re: /^\/library\.html$/, style: "discuz", kind: "library" },
|
||||
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "discuz", kind: "libraryRead" },
|
||||
{ re: /^\/library-([^/]+)\.html$/, style: "discuz", kind: "libraryDoc" },
|
||||
];
|
||||
|
||||
const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
|
||||
@@ -135,27 +194,52 @@ const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
|
||||
{ re: /^\/announcement\/(\d+)$/, kind: "announcement" },
|
||||
{ re: /^\/p\/([^/]+)$/, kind: "page" },
|
||||
{ re: /^\/leaderboard\/?$/, kind: "leaderboard" },
|
||||
// ([^/]+)$ 尾部锚定:/library/{slug}/read 不会命中详情模式(读页单独识别,?c= 走 query)
|
||||
{ re: /^\/library\/?$/, kind: "library" },
|
||||
{ re: /^\/library\/([^/]+)\/read$/, kind: "libraryRead" },
|
||||
{ re: /^\/library\/([^/]+)$/, kind: "libraryDoc" },
|
||||
];
|
||||
|
||||
/** 匹配任一伪静态风格路径(不含标准风格);命中返回风格/实体/id;page 的 slug 保持 encoded 原样 */
|
||||
export function matchPrettyPath(pathname: string): PrettyMatch | null {
|
||||
/**
|
||||
* 匹配任一伪静态风格路径(不含标准风格);命中返回风格/实体/id;
|
||||
* page/libraryDoc 的 slug 保持 encoded 原样。
|
||||
* prefer:同一模板存在多风格条目时优先返回该风格的匹配(middleware 传入当前
|
||||
* 风格,避免书库两风格同模板被判成另一风格 → 301 目标与自身相同而死循环);
|
||||
* 缺省取首条命中。
|
||||
*/
|
||||
export function matchPrettyPath(
|
||||
pathname: string,
|
||||
prefer?: Exclude<UrlStyle, "default">
|
||||
): PrettyMatch | null {
|
||||
let fallback: PrettyMatch | null = null;
|
||||
for (const p of PRETTY_PATTERNS) {
|
||||
const m = p.re.exec(pathname);
|
||||
if (!m) continue;
|
||||
if (p.kind === "page") return { style: p.style, kind: "page", slug: m[1] };
|
||||
if (p.kind === "leaderboard") return { style: p.style, kind: "leaderboard" };
|
||||
return { style: p.style, kind: p.kind, id: Number(m[1]) };
|
||||
let hit: PrettyMatch;
|
||||
if (p.kind === "page" || p.kind === "libraryDoc") {
|
||||
hit = { style: p.style, kind: p.kind, slug: m[1] };
|
||||
} else if (p.kind === "libraryRead") {
|
||||
hit = { style: p.style, kind: p.kind, slug: m[1], id: Number(m[2]) };
|
||||
} else if (p.kind === "library" || p.kind === "leaderboard") {
|
||||
hit = { style: p.style, kind: p.kind };
|
||||
} else {
|
||||
hit = { style: p.style, kind: p.kind, id: Number(m[1]) };
|
||||
}
|
||||
if (!fallback) fallback = hit;
|
||||
if (prefer && p.style === prefer) return hit;
|
||||
}
|
||||
return null;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 匹配标准实体路径;page 的 slug 保持 encoded 原样(与 URL 一致,rewrite 直接复用) */
|
||||
/** 匹配标准实体路径;page/libraryDoc/libraryRead 的 slug 保持 encoded 原样(与 URL 一致,rewrite 直接复用) */
|
||||
export function matchStandardPath(pathname: string): StandardMatch | null {
|
||||
for (const p of STANDARD_PATTERNS) {
|
||||
const m = p.re.exec(pathname);
|
||||
if (!m) continue;
|
||||
if (p.kind === "page") return { kind: "page", slug: m[1] };
|
||||
if (p.kind === "leaderboard") return { kind: "leaderboard" };
|
||||
if (p.kind === "page" || p.kind === "libraryDoc" || p.kind === "libraryRead") {
|
||||
return { kind: p.kind, slug: m[1] };
|
||||
}
|
||||
if (p.kind === "library" || p.kind === "leaderboard") return { kind: p.kind };
|
||||
return { kind: p.kind, id: Number(m[1]) };
|
||||
}
|
||||
return null;
|
||||
@@ -164,21 +248,29 @@ export function matchStandardPath(pathname: string): StandardMatch | null {
|
||||
export type StandardMatch =
|
||||
| { kind: EntityKind; id: number }
|
||||
| { kind: "page"; slug: string }
|
||||
| { kind: "libraryDoc"; slug: string }
|
||||
| { kind: "libraryRead"; slug: string }
|
||||
| { kind: "library" }
|
||||
| { kind: "leaderboard" };
|
||||
|
||||
/**
|
||||
* 反解任意实体路径(标准 + 全部伪静态风格),供 isActive 前缀判断、面包屑等
|
||||
* "从路径认实体"的场景使用。page 的 slug 解码为原始文本;数字实体统一 { kind, id }。
|
||||
* "从路径认实体"的场景使用。page/libraryDoc 的 slug 解码为原始文本;数字实体统一 { kind, id }。
|
||||
*/
|
||||
export function parseEntityPath(pathname: string): StandardMatch | null {
|
||||
const standard = matchStandardPath(pathname);
|
||||
if (standard) {
|
||||
return standard.kind === "page" ? { kind: "page", slug: decodeSlug(standard.slug) } : standard;
|
||||
return standard.kind === "page" || standard.kind === "libraryDoc" || standard.kind === "libraryRead"
|
||||
? { kind: standard.kind, slug: decodeSlug(standard.slug) }
|
||||
: standard;
|
||||
}
|
||||
const pretty = matchPrettyPath(pathname);
|
||||
if (!pretty) return null;
|
||||
if (pretty.kind === "page") return { kind: "page", slug: decodeSlug(pretty.slug) };
|
||||
if (pretty.kind === "libraryDoc") return { kind: "libraryDoc", slug: decodeSlug(pretty.slug) };
|
||||
if (pretty.kind === "libraryRead") return { kind: "libraryRead", slug: decodeSlug(pretty.slug) };
|
||||
if (pretty.kind === "leaderboard") return { kind: "leaderboard" };
|
||||
if (pretty.kind === "library") return { kind: "library" };
|
||||
return { kind: pretty.kind, id: pretty.id };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies";
|
||||
import {
|
||||
entityPath,
|
||||
leaderboardPath,
|
||||
libraryDocPathFromSlug,
|
||||
libraryDocReadPath,
|
||||
libraryPath,
|
||||
matchPrettyPath,
|
||||
matchStandardPath,
|
||||
normalizeUrlStyle,
|
||||
@@ -12,8 +15,14 @@ import {
|
||||
type UrlStyle,
|
||||
} from "@/lib/urlStyle";
|
||||
|
||||
/** 解析正整数 query:读页 ?c=(书内章序号)、伪静态详情 ?sec=(新章节地址);非法/缺省返回 null */
|
||||
function readPositiveInt(sp: URLSearchParams, key: string): number | null {
|
||||
const n = Number.parseInt(sp.get(key) ?? "", 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
// SSR 登录态保障:
|
||||
// access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转,
|
||||
// access token(j13_token,7 天)过期后,页面/RSC 请求到达时先在此静默轮转,
|
||||
// 新 cookie 同时①注入本次请求头供 layout 的 /api/me 使用 ②透传给浏览器。
|
||||
// refresh token 是一次性轮转(旧的立即吊销),而 RSC 预取与真实导航、甚至多
|
||||
// 运行时实例可能几乎同时发起轮转:
|
||||
@@ -175,6 +184,7 @@ function couldBeEntityPath(pathname: string): boolean {
|
||||
pathname.startsWith("/u/") ||
|
||||
pathname.startsWith("/announcement/") ||
|
||||
pathname.startsWith("/p/") ||
|
||||
pathname.startsWith("/library") ||
|
||||
pathname.startsWith("/leaderboard") ||
|
||||
pathname.endsWith(".html")
|
||||
);
|
||||
@@ -182,22 +192,51 @@ function couldBeEntityPath(pathname: string): boolean {
|
||||
|
||||
type UrlStyleAction = { type: "pass" } | { type: "redirect"; target: string } | { type: "rewrite"; target: string };
|
||||
|
||||
/** 反解结果 → 标准内部路由路径(rewrite 落点);page 的 slug 保持 encoded 原样 */
|
||||
/** 反解结果 → 标准内部路由路径(rewrite 落点);page/libraryDoc/libraryRead 的 slug 保持 encoded 原样。
|
||||
* 章节号来自 pretty 路径自身,随 target 的 ?c= 透传给读页 */
|
||||
function standardTarget(m: { kind: PageKind; id?: number; slug?: string }): string {
|
||||
if (m.kind === "page") return `/p/${m.slug ?? ""}`;
|
||||
if (m.kind === "libraryDoc") return `/library/${m.slug ?? ""}`;
|
||||
if (m.kind === "libraryRead") return `/library/${m.slug ?? ""}/read?c=${m.id ?? 0}`;
|
||||
if (m.kind === "library") return "/library";
|
||||
if (m.kind === "leaderboard") return "/leaderboard";
|
||||
return standardEntityPath(m.kind, m.id ?? 0);
|
||||
}
|
||||
|
||||
/** 反解结果 → 对应风格路径(301 目标);page 用 encoded slug 直接拼,避免二次编码 */
|
||||
function styledPath(style: UrlStyle, m: { kind: PageKind; id?: number; slug?: string }): string {
|
||||
/** 反解结果 → 对应风格路径(301 目标);page/libraryDoc 用 encoded slug 直接拼,避免二次编码。
|
||||
* chapterNo:标准读页的 ?c= 值(pretty 路径自身已含章号时不走此参数) */
|
||||
function styledPath(
|
||||
style: UrlStyle,
|
||||
m: { kind: PageKind; id?: number; slug?: string },
|
||||
chapterNo?: number | null
|
||||
): string {
|
||||
if (m.kind === "page") return pagePathFromSlug(style, m.slug ?? "");
|
||||
if (m.kind === "libraryDoc") return libraryDocPathFromSlug(style, m.slug ?? "");
|
||||
if (m.kind === "libraryRead") {
|
||||
return libraryDocReadPath(style, m.slug ?? "", chapterNo ?? m.id ?? 1);
|
||||
}
|
||||
if (m.kind === "library") return libraryPath(style);
|
||||
if (m.kind === "leaderboard") return leaderboardPath(style);
|
||||
return entityPath(style, m.kind, m.id ?? 0);
|
||||
}
|
||||
|
||||
function resolveUrlStyleAction(style: UrlStyle, pathname: string): UrlStyleAction {
|
||||
const pretty = matchPrettyPath(pathname);
|
||||
function resolveUrlStyleAction(
|
||||
style: UrlStyle,
|
||||
pathname: string,
|
||||
chapterNo: number | null,
|
||||
secNo: number | null
|
||||
): UrlStyleAction {
|
||||
// 伪静态详情路径 + ?sec={n}:新章节地址(/library-{slug}.html?sec=n)→ 标准读页;
|
||||
// default 风格无伪静态,301 收敛到标准读页(query ?sec= 转为落点 ?c=)
|
||||
if (secNo != null) {
|
||||
const docHit = matchPrettyPath(pathname, style === "default" ? undefined : style);
|
||||
if (docHit?.kind === "libraryDoc") {
|
||||
const target = `/library/${docHit.slug}/read?c=${secNo}`;
|
||||
return style === "default" ? { type: "redirect", target } : { type: "rewrite", target };
|
||||
}
|
||||
}
|
||||
// 书库两风格同模板:按当前风格优先匹配,否则 301 目标会与自身相同而死循环
|
||||
const pretty = matchPrettyPath(pathname, style === "default" ? undefined : style);
|
||||
const standard = pretty ? null : matchStandardPath(pathname);
|
||||
if (!pretty && !standard) return { type: "pass" };
|
||||
|
||||
@@ -210,16 +249,18 @@ function resolveUrlStyleAction(style: UrlStyle, pathname: string): UrlStyleActio
|
||||
if (pretty.style === style) {
|
||||
return { type: "rewrite", target: standardTarget(pretty) };
|
||||
}
|
||||
return { type: "redirect", target: styledPath(style, pretty) };
|
||||
return { type: "redirect", target: styledPath(style, pretty, chapterNo) };
|
||||
}
|
||||
// 标准路径 → 301 到当前风格
|
||||
return { type: "redirect", target: styledPath(style, standard!) };
|
||||
// 标准路径 → 301 到当前风格(读页的 ?c= 已并入伪静态路径段,redirect 时覆盖 query)
|
||||
return { type: "redirect", target: styledPath(style, standard!, chapterNo) };
|
||||
}
|
||||
|
||||
/** 301 目标只换 pathname,origin 与 query(?tab=/?page=)原样保留 */
|
||||
/** 301 目标只换 pathname;target 自带 query(读页 ?c= 转路径段)时覆盖原 query,否则原样保留(?tab=/?page=) */
|
||||
function urlStyleRedirect(req: NextRequest, targetPath: string): NextResponse {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = targetPath;
|
||||
const q = targetPath.indexOf("?");
|
||||
url.pathname = q >= 0 ? targetPath.slice(0, q) : targetPath;
|
||||
if (q >= 0) url.search = targetPath.slice(q);
|
||||
return NextResponse.redirect(url, 301);
|
||||
}
|
||||
|
||||
@@ -232,11 +273,19 @@ async function sessionMiddleware(req: NextRequest) {
|
||||
let styleRewrite: URL | undefined;
|
||||
if (couldBeEntityPath(req.nextUrl.pathname)) {
|
||||
const state = await getCachedPublicSiteState();
|
||||
const action = resolveUrlStyleAction(normalizeUrlStyle(state?.url_style), req.nextUrl.pathname);
|
||||
const action = resolveUrlStyleAction(
|
||||
normalizeUrlStyle(state?.url_style),
|
||||
req.nextUrl.pathname,
|
||||
readPositiveInt(req.nextUrl.searchParams, "c"),
|
||||
readPositiveInt(req.nextUrl.searchParams, "sec")
|
||||
);
|
||||
if (action.type === "redirect") return urlStyleRedirect(req, action.target);
|
||||
if (action.type === "rewrite") {
|
||||
styleRewrite = req.nextUrl.clone();
|
||||
styleRewrite.pathname = action.target;
|
||||
// 读页章节 rewrite 落点自带 ?c=,覆盖原 query;其余落点无 query,原样保留
|
||||
const q = action.target.indexOf("?");
|
||||
styleRewrite.pathname = q >= 0 ? action.target.slice(0, q) : action.target;
|
||||
if (q >= 0) styleRewrite.search = action.target.slice(q);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
frontend/package-lock.json
generated
128
frontend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||
"jszip": "^3.10.2",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -21,6 +22,7 @@
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"shiki": "^4.4.3",
|
||||
"turndown": "^7.2.4",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
},
|
||||
@@ -29,6 +31,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
@@ -655,6 +658,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mixmark-io/domino": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz",
|
||||
@@ -1371,6 +1380,13 @@
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/turndown": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
|
||||
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -1506,6 +1522,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1944,6 +1966,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.18",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
|
||||
@@ -1954,6 +1982,12 @@
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
@@ -2025,6 +2059,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
@@ -2035,6 +2075,27 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.2",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
|
||||
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -3308,6 +3369,12 @@
|
||||
"regex-recursion": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-entities": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||
@@ -3386,6 +3453,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
|
||||
@@ -3487,6 +3560,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||
@@ -3663,6 +3751,12 @@
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||
@@ -3682,6 +3776,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
@@ -3770,6 +3870,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -3878,6 +3987,19 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/turndown": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
|
||||
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"npm": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -3995,6 +4117,12 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/widgetCode.test.ts lib/smartDiff.test.ts lib/dyEmoji.test.ts"
|
||||
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/mobileTabs.test.ts lib/headerNav.test.ts lib/widgetCode.test.ts lib/smartDiff.test.ts lib/dyEmoji.test.ts lib/entryBlocks.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||
"jszip": "^3.10.2",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -23,6 +24,7 @@
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"shiki": "^4.4.3",
|
||||
"turndown": "^7.2.4",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
},
|
||||
@@ -31,6 +33,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user