feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -0,0 +1,327 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, Search, X } from "lucide-react";
import Modal from "@/components/Modal";
import { libraryDocReadPath, type UrlStyle } from "@/lib/urlStyle";
import { searchBook, type BookChapterIndex, type BookSnippet } from "@/lib/bookSearch";
/** 阅读页正文章节容器 id(read/page.tsx 的 <article>),高亮作用域 */
const ARTICLE_ID = "j13-book-article";
/** 关键词长度上限(避免超长输入卡顿) */
const MAX_QUERY = 64;
/** CSS Custom Highlight 注册名(对应 globals.css 的 ::highlight(...)) */
const HL_ALL = "j13-find";
const HL_CURRENT = "j13-find-current";
/** 特性检测:CSS Custom Highlight API(Chrome105+/Safari17.2+/FF140+),不支持时只跳转不高亮 */
function highlightsSupported(): boolean {
return (
typeof window !== "undefined" &&
typeof window.Highlight !== "undefined" &&
typeof CSS !== "undefined" &&
!!CSS.highlights
);
}
function clearHighlights(): void {
if (!highlightsSupported()) return;
CSS.highlights!.delete(HL_ALL);
CSS.highlights!.delete(HL_CURRENT);
}
/**
* 运行时注入高亮样式:构建器的 CSS 压缩器不识别 ::highlight() 会丢规则,
* 且 highlight 伪元素不继承自定义属性,这里从 :root 读出 --accent/--accent-on
* 的实际色值拼规则。hex 转带透明度的 rgba;非 hex 值原样使用。
*/
let highlightStyleInjected = false;
function ensureHighlightStyle(): void {
if (highlightStyleInjected || !highlightsSupported()) return;
const css = getComputedStyle(document.documentElement);
const accent = (css.getPropertyValue("--accent") || "").trim();
const accentOn = (css.getPropertyValue("--accent-on") || "").trim() || "#fff";
let h = accent;
if (h.length === 4) h = "#" + h.slice(1).split("").map((c) => c + c).join("");
const soft =
/^#[0-9a-f]{6}$/i.test(h)
? `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},0.24)`
: accent;
const style = document.createElement("style");
style.dataset.j13 = "find-highlight";
style.textContent =
`::highlight(${HL_ALL}){background-color:${soft}}` +
`::highlight(${HL_CURRENT}){background-color:${accent};color:${accentOn}}`;
document.head.appendChild(style);
highlightStyleInjected = true;
}
/**
* 收集正文内命中 Range:按文本节点逐个匹配(大小写不敏感子串),不改 DOM。
* 用 Range 而非包 <mark>:正文是 server 组件渲染的,直接替换文本节点会让
* React 在章节切换后的原地复用错位;Range 随章节重建自然失效,无副作用。
* 跨标签边界的词匹配不到——可接受,绝大多数中文命中落在同一文本节点。
*/
function collectRanges(root: HTMLElement, query: string): Range[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const targets: Text[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const el = node.parentElement;
if (!el || !node.nodeValue) return NodeFilter.FILTER_REJECT;
const tag = el.tagName;
if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") {
return NodeFilter.FILTER_REJECT;
}
return node.nodeValue.toLowerCase().includes(q)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
let n: Node | null;
while ((n = walker.nextNode())) targets.push(n as Text);
const ranges: Range[] = [];
for (const textNode of targets) {
const tv = textNode.nodeValue ?? "";
const low = tv.toLowerCase();
let from = 0;
for (;;) {
const idx = low.indexOf(q, from);
if (idx < 0) break;
const range = document.createRange();
range.setStart(textNode, idx);
range.setEnd(textNode, idx + q.length);
ranges.push(range);
from = idx + q.length;
}
}
return ranges;
}
/** 注册高亮(全部命中 + 当前命中两组);返回文档序命中 Range 列表 */
function paintRanges(query: string, currentOrdinal: number): Range[] {
const root = document.getElementById(ARTICLE_ID);
if (!root) return [];
const ranges = collectRanges(root, query);
if (!highlightsSupported()) return ranges;
ensureHighlightStyle();
clearHighlights();
if (ranges.length > 0) {
const all = new Highlight(...ranges);
const cur = new Highlight();
const curRange = ranges[Math.min(Math.max(currentOrdinal, 0), ranges.length - 1)];
if (curRange) cur.add(curRange);
CSS.highlights!.set(HL_ALL, all);
CSS.highlights!.set(HL_CURRENT, cur);
}
return ranges;
}
/** 滚动到命中 Range(视口垂直居中,留出吸顶头空间) */
function scrollToRange(range: Range, smooth: boolean): void {
const rect = range.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const top =
window.scrollY + rect.top - window.innerHeight / 2 + (rect.height || 16) / 2;
window.scrollTo({ top: Math.max(0, top), behavior: smooth ? "smooth" : "auto" });
}
/** 重新高亮当前章并定位到指定命中(ordinal 为章内 0 基命中序号) */
function highlightAndScroll(query: string, ordinal: number, smooth: boolean): void {
const ranges = paintRanges(query, ordinal);
const target = ranges[Math.min(Math.max(ordinal, 0), ranges.length - 1)];
if (!target) return;
// 双层 rAF:等章节切换后的布局与图片占位稳定再定位
requestAnimationFrame(() =>
requestAnimationFrame(() => scrollToRange(target, smooth))
);
}
function SnippetText({ snip }: { snip: BookSnippet }) {
return (
<span className="leading-relaxed">
{snip.before ? <span className="meta">…{snip.before}</span> : null}
<mark className="j13-find-mark">{snip.match}</mark>
{snip.after ? <span className="meta">{snip.after}…</span> : null}
</span>
);
}
export interface BookSearchProps {
slug: string;
urlStyle: UrlStyle;
/** 当前章序号(1 基) */
currentNo: number;
chapters: BookChapterIndex[];
}
export default function BookSearch({ slug, urlStyle, currentNo, chapters }: BookSearchProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
/**
* 跨章跳转待执行的高亮。组件跨章导航不重挂载(位置稳定),query 状态保留,
* 仅在 currentNo 变化、新章 DOM 提交后执行一次,因此无需把关键词写进 URL。
*/
const pendingRef = useRef<{ q: string; ordinal: number } | null>(null);
const trimmed = query.trim();
const results = useMemo(() => searchBook(chapters, trimmed), [chapters, trimmed]);
const totalHits = useMemo(() => results.reduce((acc, h) => acc + h.count, 0), [results]);
useEffect(() => {
const pending = pendingRef.current;
if (!pending) return;
pendingRef.current = null;
highlightAndScroll(pending.q, pending.ordinal, false);
}, [currentNo]);
// 关键词清空或离开读页时撤掉正文高亮
useEffect(() => {
if (!trimmed) clearHighlights();
}, [trimmed]);
useEffect(() => () => clearHighlights(), []);
const focusHit = (no: number, ordinal: number) => {
if (no === currentNo) {
highlightAndScroll(trimmed, ordinal, true);
setOpen(false);
return;
}
pendingRef.current = { q: trimmed, ordinal };
setOpen(false);
router.push(libraryDocReadPath(urlStyle, slug, no));
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && results[0]) {
const first = results[0];
focusHit(first.no, first.snippets[0]?.ordinal ?? 0);
}
};
return (
<>
<button
type="button"
onClick={() => {
setOpen(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
className="inline-flex items-center gap-2 rounded-full pl-3.5 pr-4 py-2 text-[13px] transition-colors hover:bg-[var(--panel-2)] hover:border-[color-mix(in_srgb,var(--accent)_40%,var(--line))] shrink-0 w-36 sm:w-52"
style={{ color: "var(--ink-3)", border: "1px solid var(--line)", background: "var(--panel)" }}
aria-label="搜索本书"
>
<Search size={14} className="shrink-0" aria-hidden />
<span className="truncate text-left">搜索本书</span>
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="搜索本书"
description={`共 ${chapters.length} 章 · 命中后可直接跳到对应位置`}
maxWidthClass="max-w-lg"
panelClassName="p-5 sm:p-6"
>
<div className="relative">
<Search
size={15}
className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
ref={inputRef}
type="text"
value={query}
maxLength={MAX_QUERY}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="搜索章节标题与正文"
aria-label="搜索章节标题与正文"
className="j13-search-field w-full rounded-xl pl-10 pr-9 py-2.5 text-[13.5px]"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除关键词"
className="absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={13} />
</button>
) : null}
</div>
<div className="mt-3 max-h-[56vh] overflow-y-auto j13-rail-scroll -mx-1 px-1">
{!trimmed ? (
<p className="meta text-[12.5px] py-8 text-center">
输入关键词,按章查看命中片段,点击即可跳转
</p>
) : totalHits === 0 ? (
<p className="meta text-[12.5px] py-8 text-center">全书没有找到「{trimmed}」</p>
) : (
<div className="flex flex-col gap-2">
<p className="meta text-[11.5px] px-1">
{results.length} 章命中 · 共 {totalHits} 处
</p>
{results.map((hit) => {
const isCurrent = hit.no === currentNo;
return (
<section
key={hit.no}
className="rounded-xl overflow-hidden"
style={{ border: "1px solid var(--line)" }}
>
<button
type="button"
onClick={() => focusHit(hit.no, 0)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--panel-2)] transition-colors"
>
<span
className="text-[12.5px] font-bold truncate min-w-0"
style={{ color: "var(--ink)" }}
>
第 {hit.no} 章 · {hit.title}
</span>
{isCurrent ? (
<span
className="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded"
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
>
本章
</span>
) : null}
<span className="meta text-[11px] ml-auto shrink-0 inline-flex items-center gap-0.5">
{hit.count} 处
<ChevronRight size={12} aria-hidden />
</span>
</button>
{hit.snippets.map((snip) => (
<button
key={snip.ordinal}
type="button"
onClick={() => focusHit(hit.no, snip.ordinal)}
className="block w-full text-left px-3 py-1.5 text-[12.5px] border-t border-[var(--line)] hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-2)" }}
>
<SnippetText snip={snip} />
</button>
))}
</section>
);
})}
</div>
)}
</div>
</Modal>
</>
);
}