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

88
frontend/lib/headerNav.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* 桌面端 Header 主导航配置的单一事实源:
* 与后端 service.HeaderNavBuiltinKeys 保持一致;Header 渲染、后台管理面板、公共设置 normalize 共用。
*
* 配置为有序数组,数组顺序即展示顺序:
* - builtin 只存 key,文案/图标/地址/高亮由前端注册表派生(地址随伪静态风格变化);
* - custom 存文案 + 地址(站内路径或 http(s) 外链)。
* 缺行/非法输入回落内置默认三项;显式空数组表示全部隐藏(前台不渲染导航)。
* 后续新增内置页面:在本注册表与后端白名单各加一项,后台自动出现可选项。
*/
import { footerURLAllowed, isExternalFooterURL, runeCount } from "./brand.ts";
export const HEADER_NAV_BUILTIN_KEYS = ["home", "leaderboard", "library"] as const;
export type HeaderNavBuiltinKey = (typeof HEADER_NAV_BUILTIN_KEYS)[number];
/** 内置菜单默认文案;后台面板用同一文案 */
export const HEADER_NAV_BUILTIN_LABELS: Record<HeaderNavBuiltinKey, string> = {
home: "首页",
leaderboard: "排行榜",
library: "书库",
};
export type HeaderNavBuiltinItem = { type: "builtin"; key: HeaderNavBuiltinKey };
export type HeaderNavCustomItem = {
type: "custom";
label: string;
url: string;
new_tab: boolean;
};
export type HeaderNavItem = HeaderNavBuiltinItem | HeaderNavCustomItem;
export const HEADER_NAV_LIMITS = {
items: 8,
label: 16,
url: 200,
} as const;
/** 内置默认三项(缺行时回落) */
export const DEFAULT_HEADER_NAV: HeaderNavItem[] = HEADER_NAV_BUILTIN_KEYS.map((key) => ({
type: "builtin" as const,
key,
}));
export function isHeaderNavCustom(item: HeaderNavItem): item is HeaderNavCustomItem {
return item.type === "custom";
}
/**
* 宽容解析服务端配置:未知内置 key/重复内置/非法自定义项跳过;
* 入参非数组回落默认;空数组原样返回(全部隐藏)。
*/
export function normalizeHeaderNav(raw: unknown): HeaderNavItem[] {
if (!Array.isArray(raw)) return DEFAULT_HEADER_NAV.map((item) => ({ ...item }));
const out: HeaderNavItem[] = [];
const seenBuiltin = new Set<string>();
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const rec = entry as Record<string, unknown>;
const type = String(rec.type ?? "").trim();
if (type === "builtin") {
const key = String(rec.key ?? "")
.trim()
.toLowerCase();
if (!(HEADER_NAV_BUILTIN_KEYS as readonly string[]).includes(key) || seenBuiltin.has(key)) {
continue;
}
seenBuiltin.add(key);
out.push({ type: "builtin", key: key as HeaderNavBuiltinKey });
} else if (type === "custom") {
const label = String(rec.label ?? "").trim();
const url = String(rec.url ?? "").trim();
if (!label || runeCount(label) > HEADER_NAV_LIMITS.label) continue;
if (!footerURLAllowed(url)) continue;
const omitted = rec.new_tab === undefined || rec.new_tab === null;
const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false;
out.push({ type: "custom", label, url, new_tab: newTab });
}
if (out.length >= HEADER_NAV_LIMITS.items) break;
}
return out;
}
/** 自定义菜单是否为外链(外链渲染 target=_blank) */
export function isHeaderNavExternal(url: string): boolean {
return isExternalFooterURL(url.trim());
}