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

@@ -3,6 +3,9 @@ import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies";
import {
entityPath,
leaderboardPath,
libraryDocPathFromSlug,
libraryDocReadPath,
libraryPath,
matchPrettyPath,
matchStandardPath,
normalizeUrlStyle,
@@ -12,8 +15,14 @@ import {
type UrlStyle,
} from "@/lib/urlStyle";
/** 解析正整数 query:读页 ?c=(书内章序号)、伪静态详情 ?sec=(新章节地址);非法/缺省返回 null */
function readPositiveInt(sp: URLSearchParams, key: string): number | null {
const n = Number.parseInt(sp.get(key) ?? "", 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
// SSR 登录态保障:
// access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转,
// access token(j13_token,7 天)过期后,页面/RSC 请求到达时先在此静默轮转,
// 新 cookie 同时①注入本次请求头供 layout 的 /api/me 使用 ②透传给浏览器。
// refresh token 是一次性轮转(旧的立即吊销),而 RSC 预取与真实导航、甚至多
// 运行时实例可能几乎同时发起轮转:
@@ -175,6 +184,7 @@ function couldBeEntityPath(pathname: string): boolean {
pathname.startsWith("/u/") ||
pathname.startsWith("/announcement/") ||
pathname.startsWith("/p/") ||
pathname.startsWith("/library") ||
pathname.startsWith("/leaderboard") ||
pathname.endsWith(".html")
);
@@ -182,22 +192,51 @@ function couldBeEntityPath(pathname: string): boolean {
type UrlStyleAction = { type: "pass" } | { type: "redirect"; target: string } | { type: "rewrite"; target: string };
/** 反解结果 → 标准内部路由路径(rewrite 落点);page 的 slug 保持 encoded 原样 */
/** 反解结果 → 标准内部路由路径(rewrite 落点);page/libraryDoc/libraryRead 的 slug 保持 encoded 原样。
* 章节号来自 pretty 路径自身,随 target 的 ?c= 透传给读页 */
function standardTarget(m: { kind: PageKind; id?: number; slug?: string }): string {
if (m.kind === "page") return `/p/${m.slug ?? ""}`;
if (m.kind === "libraryDoc") return `/library/${m.slug ?? ""}`;
if (m.kind === "libraryRead") return `/library/${m.slug ?? ""}/read?c=${m.id ?? 0}`;
if (m.kind === "library") return "/library";
if (m.kind === "leaderboard") return "/leaderboard";
return standardEntityPath(m.kind, m.id ?? 0);
}
/** 反解结果 → 对应风格路径(301 目标);page 用 encoded slug 直接拼,避免二次编码 */
function styledPath(style: UrlStyle, m: { kind: PageKind; id?: number; slug?: string }): string {
/** 反解结果 → 对应风格路径(301 目标);page/libraryDoc 用 encoded slug 直接拼,避免二次编码。
* chapterNo:标准读页的 ?c= 值(pretty 路径自身已含章号时不走此参数) */
function styledPath(
style: UrlStyle,
m: { kind: PageKind; id?: number; slug?: string },
chapterNo?: number | null
): string {
if (m.kind === "page") return pagePathFromSlug(style, m.slug ?? "");
if (m.kind === "libraryDoc") return libraryDocPathFromSlug(style, m.slug ?? "");
if (m.kind === "libraryRead") {
return libraryDocReadPath(style, m.slug ?? "", chapterNo ?? m.id ?? 1);
}
if (m.kind === "library") return libraryPath(style);
if (m.kind === "leaderboard") return leaderboardPath(style);
return entityPath(style, m.kind, m.id ?? 0);
}
function resolveUrlStyleAction(style: UrlStyle, pathname: string): UrlStyleAction {
const pretty = matchPrettyPath(pathname);
function resolveUrlStyleAction(
style: UrlStyle,
pathname: string,
chapterNo: number | null,
secNo: number | null
): UrlStyleAction {
// 伪静态详情路径 + ?sec={n}:新章节地址(/library-{slug}.html?sec=n)→ 标准读页;
// default 风格无伪静态,301 收敛到标准读页(query ?sec= 转为落点 ?c=)
if (secNo != null) {
const docHit = matchPrettyPath(pathname, style === "default" ? undefined : style);
if (docHit?.kind === "libraryDoc") {
const target = `/library/${docHit.slug}/read?c=${secNo}`;
return style === "default" ? { type: "redirect", target } : { type: "rewrite", target };
}
}
// 书库两风格同模板:按当前风格优先匹配,否则 301 目标会与自身相同而死循环
const pretty = matchPrettyPath(pathname, style === "default" ? undefined : style);
const standard = pretty ? null : matchStandardPath(pathname);
if (!pretty && !standard) return { type: "pass" };
@@ -210,16 +249,18 @@ function resolveUrlStyleAction(style: UrlStyle, pathname: string): UrlStyleActio
if (pretty.style === style) {
return { type: "rewrite", target: standardTarget(pretty) };
}
return { type: "redirect", target: styledPath(style, pretty) };
return { type: "redirect", target: styledPath(style, pretty, chapterNo) };
}
// 标准路径 → 301 到当前风格
return { type: "redirect", target: styledPath(style, standard!) };
// 标准路径 → 301 到当前风格(读页的 ?c= 已并入伪静态路径段,redirect 时覆盖 query)
return { type: "redirect", target: styledPath(style, standard!, chapterNo) };
}
/** 301 目标只换 pathname,origin 与 query(?tab=/?page=)原样保留 */
/** 301 目标只换 pathname;target 自带 query(读页 ?c= 转路径段)时覆盖原 query,否则原样保留(?tab=/?page=) */
function urlStyleRedirect(req: NextRequest, targetPath: string): NextResponse {
const url = req.nextUrl.clone();
url.pathname = targetPath;
const q = targetPath.indexOf("?");
url.pathname = q >= 0 ? targetPath.slice(0, q) : targetPath;
if (q >= 0) url.search = targetPath.slice(q);
return NextResponse.redirect(url, 301);
}
@@ -232,11 +273,19 @@ async function sessionMiddleware(req: NextRequest) {
let styleRewrite: URL | undefined;
if (couldBeEntityPath(req.nextUrl.pathname)) {
const state = await getCachedPublicSiteState();
const action = resolveUrlStyleAction(normalizeUrlStyle(state?.url_style), req.nextUrl.pathname);
const action = resolveUrlStyleAction(
normalizeUrlStyle(state?.url_style),
req.nextUrl.pathname,
readPositiveInt(req.nextUrl.searchParams, "c"),
readPositiveInt(req.nextUrl.searchParams, "sec")
);
if (action.type === "redirect") return urlStyleRedirect(req, action.target);
if (action.type === "rewrite") {
styleRewrite = req.nextUrl.clone();
styleRewrite.pathname = action.target;
// 读页章节 rewrite 落点自带 ?c=,覆盖原 query;其余落点无 query,原样保留
const q = action.target.indexOf("?");
styleRewrite.pathname = q >= 0 ? action.target.slice(0, q) : action.target;
if (q >= 0) styleRewrite.search = action.target.slice(q);
}
}