feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
88
frontend/lib/headerNav.ts
Normal file
88
frontend/lib/headerNav.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 桌面端 Header 主导航配置的单一事实源:
|
||||
* 与后端 service.HeaderNavBuiltinKeys 保持一致;Header 渲染、后台管理面板、公共设置 normalize 共用。
|
||||
*
|
||||
* 配置为有序数组,数组顺序即展示顺序:
|
||||
* - builtin 只存 key,文案/图标/地址/高亮由前端注册表派生(地址随伪静态风格变化);
|
||||
* - custom 存文案 + 地址(站内路径或 http(s) 外链)。
|
||||
* 缺行/非法输入回落内置默认三项;显式空数组表示全部隐藏(前台不渲染导航)。
|
||||
* 后续新增内置页面:在本注册表与后端白名单各加一项,后台自动出现可选项。
|
||||
*/
|
||||
|
||||
import { footerURLAllowed, isExternalFooterURL, runeCount } from "./brand.ts";
|
||||
|
||||
export const HEADER_NAV_BUILTIN_KEYS = ["home", "leaderboard", "library"] as const;
|
||||
|
||||
export type HeaderNavBuiltinKey = (typeof HEADER_NAV_BUILTIN_KEYS)[number];
|
||||
|
||||
/** 内置菜单默认文案;后台面板用同一文案 */
|
||||
export const HEADER_NAV_BUILTIN_LABELS: Record<HeaderNavBuiltinKey, string> = {
|
||||
home: "首页",
|
||||
leaderboard: "排行榜",
|
||||
library: "书库",
|
||||
};
|
||||
|
||||
export type HeaderNavBuiltinItem = { type: "builtin"; key: HeaderNavBuiltinKey };
|
||||
export type HeaderNavCustomItem = {
|
||||
type: "custom";
|
||||
label: string;
|
||||
url: string;
|
||||
new_tab: boolean;
|
||||
};
|
||||
export type HeaderNavItem = HeaderNavBuiltinItem | HeaderNavCustomItem;
|
||||
|
||||
export const HEADER_NAV_LIMITS = {
|
||||
items: 8,
|
||||
label: 16,
|
||||
url: 200,
|
||||
} as const;
|
||||
|
||||
/** 内置默认三项(缺行时回落) */
|
||||
export const DEFAULT_HEADER_NAV: HeaderNavItem[] = HEADER_NAV_BUILTIN_KEYS.map((key) => ({
|
||||
type: "builtin" as const,
|
||||
key,
|
||||
}));
|
||||
|
||||
export function isHeaderNavCustom(item: HeaderNavItem): item is HeaderNavCustomItem {
|
||||
return item.type === "custom";
|
||||
}
|
||||
|
||||
/**
|
||||
* 宽容解析服务端配置:未知内置 key/重复内置/非法自定义项跳过;
|
||||
* 入参非数组回落默认;空数组原样返回(全部隐藏)。
|
||||
*/
|
||||
export function normalizeHeaderNav(raw: unknown): HeaderNavItem[] {
|
||||
if (!Array.isArray(raw)) return DEFAULT_HEADER_NAV.map((item) => ({ ...item }));
|
||||
const out: HeaderNavItem[] = [];
|
||||
const seenBuiltin = new Set<string>();
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const rec = entry as Record<string, unknown>;
|
||||
const type = String(rec.type ?? "").trim();
|
||||
if (type === "builtin") {
|
||||
const key = String(rec.key ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!(HEADER_NAV_BUILTIN_KEYS as readonly string[]).includes(key) || seenBuiltin.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenBuiltin.add(key);
|
||||
out.push({ type: "builtin", key: key as HeaderNavBuiltinKey });
|
||||
} else if (type === "custom") {
|
||||
const label = String(rec.label ?? "").trim();
|
||||
const url = String(rec.url ?? "").trim();
|
||||
if (!label || runeCount(label) > HEADER_NAV_LIMITS.label) continue;
|
||||
if (!footerURLAllowed(url)) continue;
|
||||
const omitted = rec.new_tab === undefined || rec.new_tab === null;
|
||||
const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false;
|
||||
out.push({ type: "custom", label, url, new_tab: newTab });
|
||||
}
|
||||
if (out.length >= HEADER_NAV_LIMITS.items) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 自定义菜单是否为外链(外链渲染 target=_blank) */
|
||||
export function isHeaderNavExternal(url: string): boolean {
|
||||
return isExternalFooterURL(url.trim());
|
||||
}
|
||||
Reference in New Issue
Block a user