feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user