feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
@@ -3,16 +3,18 @@
|
||||
* middleware(改写/301)、URL 构造器、后台设置、测试共用本模块。
|
||||
* 风格枚举与后端 service.UrlStyles 保持一致:default / html / discuz。
|
||||
*
|
||||
* 各风格模板(自建页 slug 与排行榜也参与;slug 非 ASCII 时按 encodeURIComponent 编码):
|
||||
* default: /post/123 /u/5 /announcement/1 /p/about /leaderboard
|
||||
* html: /post-123.html /user-5.html /announcement-1.html /p-about.html /leaderboard.html
|
||||
* discuz: /thread-123.html /space-uid-5.html /notice-1.html /page-about.html /rank.html
|
||||
* 各风格模板(自建页 slug、排行榜与书库也参与;slug 非 ASCII 时按 encodeURIComponent 编码):
|
||||
* default: /post/123 /u/5 /announcement/1 /p/about /leaderboard /library /library/{slug} /library/{slug}/read?c={书内章号}
|
||||
* html: /post-123.html /user-5.html /announcement-1.html /p-about.html /leaderboard.html /library.html /library-{slug}.html (章节:/library-{slug}.html?sec={书内章号})
|
||||
* discuz: /thread-123.html /space-uid-5.html /notice-1.html /page-about.html /rank.html (书库两风格同 html 模板)
|
||||
* 书库章节走详情路径 + ?sec= 查询参数(章内子目录锚点 #e-{sec}-{章内序号});旧版 -read-{n} 路径
|
||||
* 不再生成,但正则保留可解析(middleware 改写回标准读页),旧链接不失效。
|
||||
*/
|
||||
|
||||
export type UrlStyle = "default" | "html" | "discuz";
|
||||
export type EntityKind = "post" | "user" | "announcement";
|
||||
/** 全部可反解的实体类型;page 的 id 是 slug(字符串),leaderboard 是固定单页无 id */
|
||||
export type PageKind = EntityKind | "page" | "leaderboard";
|
||||
/** 全部可反解的实体类型;page/libraryDoc/libraryRead 的 id 是 slug(字符串),leaderboard/library 是固定单页无 id */
|
||||
export type PageKind = EntityKind | "page" | "leaderboard" | "library" | "libraryDoc" | "libraryRead";
|
||||
|
||||
export const URL_STYLES: readonly UrlStyle[] = ["default", "html", "discuz"];
|
||||
|
||||
@@ -50,6 +52,18 @@ const LEADERBOARD_TEMPLATES: Record<Exclude<UrlStyle, "default">, string> = {
|
||||
discuz: "/rank.html",
|
||||
};
|
||||
|
||||
/** 书库伪静态模板:两风格统一(书库非 Discuz 原生概念);章节在详情路径上追加 ?sec= */
|
||||
const LIBRARY_TEMPLATES: Record<Exclude<UrlStyle, "default">, { list: string; doc: (slug: string) => string }> = {
|
||||
html: {
|
||||
list: "/library.html",
|
||||
doc: (slug) => `/library-${slug}.html`,
|
||||
},
|
||||
discuz: {
|
||||
list: "/library.html",
|
||||
doc: (slug) => `/library-${slug}.html`,
|
||||
},
|
||||
};
|
||||
|
||||
/** id 非法(≤0/非有限数)时返回空串,调用方按异常路径处理 */
|
||||
function guardId(id: number): number | null {
|
||||
return Number.isFinite(id) && id > 0 ? id : null;
|
||||
@@ -97,6 +111,40 @@ export function leaderboardPath(style: UrlStyle): string {
|
||||
return style === "default" ? "/leaderboard" : LEADERBOARD_TEMPLATES[style];
|
||||
}
|
||||
|
||||
/** 书库列表路径:default 标准路径,html/discuz 伪静态(两风格统一模板) */
|
||||
export function libraryPath(style: UrlStyle): string {
|
||||
return style === "default" ? "/library" : LIBRARY_TEMPLATES[style].list;
|
||||
}
|
||||
|
||||
/** 书库条目详情路径 */
|
||||
export function libraryDocPath(style: UrlStyle, slug: string): string {
|
||||
return libraryDocPathFromSlug(style, encodeURIComponent(slug.trim()));
|
||||
}
|
||||
|
||||
/** 书库条目详情路径(slug 已是 URL 编码形态);middleware 301 复用,避免二次编码 */
|
||||
export function libraryDocPathFromSlug(style: UrlStyle, encodedSlug: string): string {
|
||||
if (style === "default") {
|
||||
return encodedSlug ? `/library/${encodedSlug}` : "/library";
|
||||
}
|
||||
return encodedSlug ? LIBRARY_TEMPLATES[style].doc(encodedSlug) : libraryPath(style);
|
||||
}
|
||||
|
||||
/** 书库阅读页路径。
|
||||
* chapterNo 为书内章序号(1 起,非章节全局 id):
|
||||
* default → /library/{slug}/read?c={n};html/discuz → /library-{slug}.html?sec={n}(缺省第一章)。
|
||||
* 章内子目录锚点为 #e-{sec}-{章内序号}(见读页),如 ?sec=1#e-1-1 */
|
||||
export function libraryDocReadPath(style: UrlStyle, slug: string, chapterNo?: number): string {
|
||||
const enc = encodeURIComponent(slug.trim());
|
||||
if (style !== "default") {
|
||||
if (!enc) return libraryPath(style);
|
||||
const no = guardId(chapterNo ?? 0) ?? 1;
|
||||
return `${LIBRARY_TEMPLATES[style].doc(enc)}?sec=${no}`;
|
||||
}
|
||||
const base = enc ? `/library/${enc}/read` : "/library";
|
||||
const no = guardId(chapterNo ?? 0);
|
||||
return no ? `${base}?c=${no}` : base;
|
||||
}
|
||||
|
||||
/** 实体对应风格下的标准/pretty 路径(内部与 middleware 共用) */
|
||||
export function entityPath(style: UrlStyle, kind: EntityKind, id: number): string {
|
||||
if (kind === "post") return postPath(style, id);
|
||||
@@ -112,21 +160,32 @@ export function standardEntityPath(kind: EntityKind, id: number): string {
|
||||
export type PrettyMatch =
|
||||
| { style: Exclude<UrlStyle, "default">; kind: EntityKind; id: number }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "page"; slug: string }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "libraryDoc"; slug: string }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "libraryRead"; slug: string; id: number }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "library" }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "leaderboard" };
|
||||
|
||||
// 精确锚定:id 仅纯数字,天然排除 /post/123/edit 这类子路径与伪造路径;
|
||||
// page 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用
|
||||
// page/libraryDoc 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用。
|
||||
// 书库两风格同模板:html 条目在前作首条兜底,middleware 按 prefer 优先取当前风格;
|
||||
// 旧版章节模板 -read-{n} 仍须排在详情模板之前(否则会被 libraryDoc 的 [^/]+ 吞掉 -read-2),仅用于旧链接解析
|
||||
const PRETTY_PATTERNS: { re: RegExp; style: Exclude<UrlStyle, "default">; kind: PageKind }[] = [
|
||||
{ re: /^\/post-(\d+)\.html$/, style: "html", kind: "post" },
|
||||
{ re: /^\/user-(\d+)\.html$/, style: "html", kind: "user" },
|
||||
{ re: /^\/announcement-(\d+)\.html$/, style: "html", kind: "announcement" },
|
||||
{ re: /^\/p-([^/]+)\.html$/, style: "html", kind: "page" },
|
||||
{ re: /^\/leaderboard\.html$/, style: "html", kind: "leaderboard" },
|
||||
{ re: /^\/library\.html$/, style: "html", kind: "library" },
|
||||
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "html", kind: "libraryRead" },
|
||||
{ re: /^\/library-([^/]+)\.html$/, style: "html", kind: "libraryDoc" },
|
||||
{ re: /^\/thread-(\d+)\.html$/, style: "discuz", kind: "post" },
|
||||
{ re: /^\/space-uid-(\d+)\.html$/, style: "discuz", kind: "user" },
|
||||
{ re: /^\/notice-(\d+)\.html$/, style: "discuz", kind: "announcement" },
|
||||
{ re: /^\/page-([^/]+)\.html$/, style: "discuz", kind: "page" },
|
||||
{ re: /^\/rank\.html$/, style: "discuz", kind: "leaderboard" },
|
||||
{ re: /^\/library\.html$/, style: "discuz", kind: "library" },
|
||||
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "discuz", kind: "libraryRead" },
|
||||
{ re: /^\/library-([^/]+)\.html$/, style: "discuz", kind: "libraryDoc" },
|
||||
];
|
||||
|
||||
const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
|
||||
@@ -135,27 +194,52 @@ const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
|
||||
{ re: /^\/announcement\/(\d+)$/, kind: "announcement" },
|
||||
{ re: /^\/p\/([^/]+)$/, kind: "page" },
|
||||
{ re: /^\/leaderboard\/?$/, kind: "leaderboard" },
|
||||
// ([^/]+)$ 尾部锚定:/library/{slug}/read 不会命中详情模式(读页单独识别,?c= 走 query)
|
||||
{ re: /^\/library\/?$/, kind: "library" },
|
||||
{ re: /^\/library\/([^/]+)\/read$/, kind: "libraryRead" },
|
||||
{ re: /^\/library\/([^/]+)$/, kind: "libraryDoc" },
|
||||
];
|
||||
|
||||
/** 匹配任一伪静态风格路径(不含标准风格);命中返回风格/实体/id;page 的 slug 保持 encoded 原样 */
|
||||
export function matchPrettyPath(pathname: string): PrettyMatch | null {
|
||||
/**
|
||||
* 匹配任一伪静态风格路径(不含标准风格);命中返回风格/实体/id;
|
||||
* page/libraryDoc 的 slug 保持 encoded 原样。
|
||||
* prefer:同一模板存在多风格条目时优先返回该风格的匹配(middleware 传入当前
|
||||
* 风格,避免书库两风格同模板被判成另一风格 → 301 目标与自身相同而死循环);
|
||||
* 缺省取首条命中。
|
||||
*/
|
||||
export function matchPrettyPath(
|
||||
pathname: string,
|
||||
prefer?: Exclude<UrlStyle, "default">
|
||||
): PrettyMatch | null {
|
||||
let fallback: PrettyMatch | null = null;
|
||||
for (const p of PRETTY_PATTERNS) {
|
||||
const m = p.re.exec(pathname);
|
||||
if (!m) continue;
|
||||
if (p.kind === "page") return { style: p.style, kind: "page", slug: m[1] };
|
||||
if (p.kind === "leaderboard") return { style: p.style, kind: "leaderboard" };
|
||||
return { style: p.style, kind: p.kind, id: Number(m[1]) };
|
||||
let hit: PrettyMatch;
|
||||
if (p.kind === "page" || p.kind === "libraryDoc") {
|
||||
hit = { style: p.style, kind: p.kind, slug: m[1] };
|
||||
} else if (p.kind === "libraryRead") {
|
||||
hit = { style: p.style, kind: p.kind, slug: m[1], id: Number(m[2]) };
|
||||
} else if (p.kind === "library" || p.kind === "leaderboard") {
|
||||
hit = { style: p.style, kind: p.kind };
|
||||
} else {
|
||||
hit = { style: p.style, kind: p.kind, id: Number(m[1]) };
|
||||
}
|
||||
if (!fallback) fallback = hit;
|
||||
if (prefer && p.style === prefer) return hit;
|
||||
}
|
||||
return null;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 匹配标准实体路径;page 的 slug 保持 encoded 原样(与 URL 一致,rewrite 直接复用) */
|
||||
/** 匹配标准实体路径;page/libraryDoc/libraryRead 的 slug 保持 encoded 原样(与 URL 一致,rewrite 直接复用) */
|
||||
export function matchStandardPath(pathname: string): StandardMatch | null {
|
||||
for (const p of STANDARD_PATTERNS) {
|
||||
const m = p.re.exec(pathname);
|
||||
if (!m) continue;
|
||||
if (p.kind === "page") return { kind: "page", slug: m[1] };
|
||||
if (p.kind === "leaderboard") return { kind: "leaderboard" };
|
||||
if (p.kind === "page" || p.kind === "libraryDoc" || p.kind === "libraryRead") {
|
||||
return { kind: p.kind, slug: m[1] };
|
||||
}
|
||||
if (p.kind === "library" || p.kind === "leaderboard") return { kind: p.kind };
|
||||
return { kind: p.kind, id: Number(m[1]) };
|
||||
}
|
||||
return null;
|
||||
@@ -164,21 +248,29 @@ export function matchStandardPath(pathname: string): StandardMatch | null {
|
||||
export type StandardMatch =
|
||||
| { kind: EntityKind; id: number }
|
||||
| { kind: "page"; slug: string }
|
||||
| { kind: "libraryDoc"; slug: string }
|
||||
| { kind: "libraryRead"; slug: string }
|
||||
| { kind: "library" }
|
||||
| { kind: "leaderboard" };
|
||||
|
||||
/**
|
||||
* 反解任意实体路径(标准 + 全部伪静态风格),供 isActive 前缀判断、面包屑等
|
||||
* "从路径认实体"的场景使用。page 的 slug 解码为原始文本;数字实体统一 { kind, id }。
|
||||
* "从路径认实体"的场景使用。page/libraryDoc 的 slug 解码为原始文本;数字实体统一 { kind, id }。
|
||||
*/
|
||||
export function parseEntityPath(pathname: string): StandardMatch | null {
|
||||
const standard = matchStandardPath(pathname);
|
||||
if (standard) {
|
||||
return standard.kind === "page" ? { kind: "page", slug: decodeSlug(standard.slug) } : standard;
|
||||
return standard.kind === "page" || standard.kind === "libraryDoc" || standard.kind === "libraryRead"
|
||||
? { kind: standard.kind, slug: decodeSlug(standard.slug) }
|
||||
: standard;
|
||||
}
|
||||
const pretty = matchPrettyPath(pathname);
|
||||
if (!pretty) return null;
|
||||
if (pretty.kind === "page") return { kind: "page", slug: decodeSlug(pretty.slug) };
|
||||
if (pretty.kind === "libraryDoc") return { kind: "libraryDoc", slug: decodeSlug(pretty.slug) };
|
||||
if (pretty.kind === "libraryRead") return { kind: "libraryRead", slug: decodeSlug(pretty.slug) };
|
||||
if (pretty.kind === "leaderboard") return { kind: "leaderboard" };
|
||||
if (pretty.kind === "library") return { kind: "library" };
|
||||
return { kind: pretty.kind, id: pretty.id };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user