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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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