feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user