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

@@ -1,11 +1,19 @@
/** 统一拆分正文中的 [hide] / [timeline] 指令块(忽略代码围栏) */
/** 统一拆分正文中的 [hide] / [timeline] / [entries] 指令块(忽略代码围栏) */
import {
splitHideSegments,
type HideKind,
type HideSegment,
} from "@/lib/hideBlocks";
import { parseTimelineBody, type TimelineItem } from "@/lib/timelineBlocks";
} from "./hideBlocks.ts";
import { parseTimelineBody, type TimelineItem } from "./timelineBlocks.ts";
import {
isEntriesClose,
isEntriesOffOpen,
isEntriesOpen,
parseEntriesBody,
wrapImplicitEntries,
type EntryItem,
} from "./entryBlocks.ts";
export type ContentSegment =
| { type: "md"; text: string }
@@ -16,7 +24,8 @@ export type ContentSegment =
locked: boolean;
body: string;
}
| { type: "timeline"; items: TimelineItem[]; rawBody: string };
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
| { type: "entries"; items: EntryItem[]; rawBody: string };
function isTimelineClose(line: string): boolean {
return line.trim().toLowerCase() === "[/timeline]";
@@ -29,11 +38,23 @@ function isTimelineOpen(trimmed: string): boolean {
/**
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
* opts.entriesAuto:书级「全章节条目卡模式」开关——开启时按 wrapImplicitEntries
* 把「## 标题 + @tags/@summary/@source/字段::值」写法包上 [entries] 壳再走统一拆分;
* 关闭(含帖子/评论等非书场景)时均按普通 Markdown 渲染,条目卡只能用显式 [entries] 块。
* [entries:off] 块内始终保持普通 Markdown。
*/
export function splitContentSegments(content: string): ContentSegment[] {
export function splitContentSegments(
content: string,
opts?: { entriesAuto?: boolean }
): ContentSegment[] {
if (!content) return [{ type: "md", text: "" }];
if (opts?.entriesAuto) {
content = wrapImplicitEntries(content);
}
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const raw: Array<{ type: "md"; text: string } | { type: "timeline"; body: string }> = [];
const raw: Array<
{ type: "md"; text: string } | { type: "timeline"; body: string } | { type: "entries"; body: string }
> = [];
let buf: string[] = [];
let inCode = false;
let i = 0;
@@ -44,6 +65,54 @@ export function splitContentSegments(content: string): ContentSegment[] {
buf = [];
};
/** 从 i 行的开标签扫到匹配闭标签;忽略代码围栏,嵌套开标签视为失败(返回 null,调用方按 md 处理) */
const scanBlock = (
isOpen: (t: string) => boolean,
isClose: (t: string) => boolean
): { body: string; end: number } | null => {
const bodyLines: string[] = [];
let j = i + 1;
let innerCode = false;
while (j < lines.length) {
const inner = lines[j].trim();
if (inner.startsWith("```")) {
innerCode = !innerCode;
bodyLines.push(lines[j]);
j++;
continue;
}
if (innerCode) {
bodyLines.push(lines[j]);
j++;
continue;
}
if (isOpen(inner)) return null;
if (isClose(inner)) return { body: bodyLines.join("\n"), end: j };
bodyLines.push(lines[j]);
j++;
}
return null;
};
/** 开闭标签块(timeline/entries)公共处理:失败按 md,成功推进 i */
const takeBlock = (
line: string,
isOpen: (t: string) => boolean,
isClose: (t: string) => boolean,
type: "timeline" | "entries"
): boolean => {
const hit = scanBlock(isOpen, isClose);
if (!hit) {
buf.push(line);
i++;
return true;
}
flushMd();
raw.push({ type, body: hit.body });
i = hit.end + 1;
return true;
};
while (i < lines.length) {
const trimmed = lines[i].trim();
if (trimmed.startsWith("```")) {
@@ -59,45 +128,26 @@ export function splitContentSegments(content: string): ContentSegment[] {
}
if (isTimelineOpen(trimmed)) {
flushMd();
const bodyLines: string[] = [];
let j = i + 1;
let innerCode = false;
let found = false;
while (j < lines.length) {
const inner = lines[j].trim();
if (inner.startsWith("```")) {
innerCode = !innerCode;
bodyLines.push(lines[j]);
j++;
continue;
}
if (innerCode) {
bodyLines.push(lines[j]);
j++;
continue;
}
if (isTimelineOpen(inner)) {
// 嵌套视为失败:整段当 md
buf.push(lines[i]);
i++;
found = false;
break;
}
if (isTimelineClose(inner)) {
found = true;
break;
}
bodyLines.push(lines[j]);
j++;
}
if (!found) {
takeBlock(lines[i], isTimelineOpen, isTimelineClose, "timeline");
continue;
}
if (isEntriesOpen(trimmed)) {
takeBlock(lines[i], isEntriesOpen, isEntriesClose, "entries");
continue;
}
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
if (isEntriesOffOpen(trimmed)) {
const hit = scanBlock(isEntriesOffOpen, isEntriesClose);
if (!hit) {
buf.push(lines[i]);
i++;
continue;
}
raw.push({ type: "timeline", body: bodyLines.join("\n") });
i = j + 1;
buf.push(hit.body);
i = hit.end + 1;
continue;
}
@@ -116,6 +166,14 @@ export function splitContentSegments(content: string): ContentSegment[] {
});
continue;
}
if (part.type === "entries") {
out.push({
type: "entries",
items: parseEntriesBody(part.body),
rawBody: part.body,
});
continue;
}
const hideSegs = splitHideSegments(part.text) as HideSegment[];
for (const s of hideSegs) {
if (s.type === "md") {