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,179 @@
// 自定义侧栏工具的 Shadow DOM 运行时(仅浏览器端,由 CustomWidgetBlock 的 effect 调用):
// - 每个工具实例独占一个 shadow root:用户 CSS 注入 shadow 内,天然不外泄;
// - 用户 HTML 挂在 .tool-root 下,用户 JS 以函数形式手动执行,
// 通过参数拿到 (root, shadow, toolId, registerCleanup),DOM 查找一律从 root 出发;
// - mount / unmount 幂等:同一 host 复用 shadow root、先清空再注入,
// 兼容 React StrictMode 双调用、依赖变更重挂与同页多个同款工具实例。
export interface WidgetSource {
id: number;
html: string;
css: string;
js: string;
}
/** 用户脚本通过 registerCleanup 注册的卸载回调(停定时器、移除监听等) */
export type WidgetCleanup = () => void;
/** 传给用户脚本的运行时上下文 */
export interface WidgetRuntimeContext {
/** 工具根节点:所有 querySelector / getElementById 都应从这里出发 */
root: HTMLElement;
shadow: ShadowRoot;
toolId: number;
registerCleanup: (fn: WidgetCleanup) => void;
}
export interface WidgetHandle {
unmount: () => void;
}
// shadow 边界内没有 Tailwind preflight,这里补一份最小 reset:
// 只做「盒模型一致 + 外边距归零 + 字体颜色继承」,视觉仍完全交给用户 CSS。
// CSS 变量(var(--accent) 等)沿 DOM 继承,可自动穿过 shadow 边界,无需在此声明。
const BASE_CSS = `
:host {
display: block;
min-width: 0;
}
.tool-root {
overflow: hidden;
min-width: 0;
}
.tool-root,
.tool-root *,
.tool-root *::before,
.tool-root *::after {
box-sizing: border-box;
}
.tool-root blockquote,
.tool-root dl,
.tool-root dd,
.tool-root h1,
.tool-root h2,
.tool-root h3,
.tool-root h4,
.tool-root h5,
.tool-root h6,
.tool-root hr,
.tool-root figure,
.tool-root p,
.tool-root pre {
margin: 0;
}
.tool-root button,
.tool-root input,
.tool-root select,
.tool-root textarea {
font: inherit;
letter-spacing: inherit;
color: inherit;
}
.tool-root button {
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
}
.tool-root img,
.tool-root svg,
.tool-root video,
.tool-root canvas {
display: block;
max-width: 100%;
}
`.trim();
function reportError(toolId: number, phase: string, err: unknown): void {
// 用户代码出错不静默:统一带工具 id 前缀,便于在控制台定位
console.error(`[自定义工具#${toolId}] ${phase}失败:`, err);
}
/**
* 把工具挂载到 host:attachShadow → 注入基础/用户样式 → 放 .tool-root → 执行用户 JS。
* 返回的 unmount 幂等,重复调用只有第一次生效。
*/
export function mountWidget(host: HTMLElement, source: WidgetSource): WidgetHandle {
const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
// 幂等重挂(StrictMode / 依赖变更):清掉上一次的全部内容后重建
shadow.replaceChildren();
const baseStyle = document.createElement("style");
baseStyle.dataset.widgetBase = "";
baseStyle.textContent = BASE_CSS;
shadow.appendChild(baseStyle);
if (source.css.trim()) {
const userStyle = document.createElement("style");
userStyle.dataset.widgetStyle = String(source.id);
// shadow 内天然隔离,用户 CSS 原样注入、不再加选择器前缀
userStyle.textContent = source.css;
shadow.appendChild(userStyle);
}
const root = document.createElement("div");
root.className = "tool-root";
root.dataset.tool = String(source.id);
if (source.html.trim()) root.innerHTML = source.html;
shadow.appendChild(root);
const cleanups: WidgetCleanup[] = [];
let disposed = false;
const context: WidgetRuntimeContext = {
root,
shadow,
toolId: source.id,
registerCleanup(fn) {
if (disposed) {
// 卸载后注册没有意义,直接丢弃并提示,避免用户误以为还会执行
try {
fn();
} catch (err) {
reportError(source.id, "卸载后注册的 cleanup 执行", err);
}
return;
}
if (typeof fn === "function") cleanups.push(fn);
},
};
if (source.js.trim()) {
try {
// 站长可信内容:函数构造器手动执行,参数即工具运行时上下文
const fn = new Function(
"root",
"shadow",
"toolId",
"registerCleanup",
`"use strict";\n${source.js}`
) as (
root: HTMLElement,
shadow: ShadowRoot,
toolId: number,
registerCleanup: WidgetRuntimeContext["registerCleanup"]
) => unknown;
fn(context.root, context.shadow, context.toolId, context.registerCleanup);
} catch (err) {
reportError(source.id, "脚本执行", err);
}
}
return {
unmount() {
if (disposed) return;
disposed = true;
// 后注册的先执行,符合多数资源(监听/定时器)的释放直觉
for (let i = cleanups.length - 1; i >= 0; i--) {
try {
cleanups[i]();
} catch (err) {
reportError(source.id, "cleanup 执行", err);
}
}
cleanups.length = 0;
shadow.replaceChildren();
},
};
}