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

@@ -1,14 +1,17 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import type { SidebarWidgetPublic, User } from "@/lib/api";
import { scopeWidgetCss } from "@/lib/widgetCode";
import { mountWidget } from "@/lib/widgetRuntime";
import { ensureWidgetApi } from "@/lib/widgetApi";
// 站长自定义工具块:HTML/CSS 随 SSR 直出(dangerouslySetInnerHTML),
// JS 在客户端挂载时注入执行、卸载时移除。站长可信内容,不做消毒/沙箱。
// CSS 经 scopeWidgetCss 限定在 `.j13-widget-{id}` 容器内,不污染页面其他区域;
// 工具内可直接用站点主题变量(var(--accent) 等)与 window.J13 接口。
// 站长自定义工具块:所有工具统一在 Shadow DOM 内渲染(attachShadow 是客户端能力,
// 因此服务端只输出空容器,HTML/CSS/JS 全部在客户端 effect 中注入,无水合不一致)。
// - CSS 注入 shadow 内,不污染全站,也不被全站样式污染;
// - JS 以函数形式执行,接收 (root, shadow, toolId, registerCleanup),
// DOM 查找从 root 出发,同页多个同款工具互不干扰;
// - unmount 时统一执行 cleanup 并清空 shadow。
// 站长可信内容,不做消毒/沙箱。
export default function CustomWidgetBlock({
widget,
viewer,
@@ -16,39 +19,36 @@ export default function CustomWidgetBlock({
widget: SidebarWidgetPublic;
viewer?: User | null;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLElement>(null);
// 先于站长 JS 建立站点接口(声明顺序保证本 effect 先执行)
useEffect(() => {
// 服务端用 useEffect 避免告警,客户端用 layout effect 首帧前完成注入,避免闪烁
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
// 两个 effect 同类型、按声明顺序执行:先建立站点接口,再挂载工具,
// 保证用户脚本首帧同步读取 window.J13 时接口已就绪。
useIsomorphicLayoutEffect(() => {
ensureWidgetApi(viewer);
}, [viewer]);
useEffect(() => {
useIsomorphicLayoutEffect(() => {
const host = hostRef.current;
if (!host || !widget.js) return;
const script = document.createElement("script");
script.dataset.widgetScript = String(widget.id);
script.textContent = widget.js;
host.appendChild(script);
return () => {
script.remove();
};
}, [widget.id, widget.js]);
if (!host) return;
const handle = mountWidget(host, {
id: widget.id,
html: widget.html,
css: widget.css,
js: widget.js,
});
return () => handle.unmount();
}, [widget.id, widget.html, widget.css, widget.js]);
return (
<section
className={`j13-widget-body j13-widget-${widget.id}`}
ref={hostRef}
className="j13-widget-body"
data-widget-id={widget.id}
data-widget-name={widget.name}
>
{widget.css ? (
<style dangerouslySetInnerHTML={{ __html: scopeWidgetCss(widget.css, widget.id) }} />
) : null}
{widget.html ? (
<div className="j13-widget-html" dangerouslySetInnerHTML={{ __html: widget.html }} />
) : null}
{/* 自定义 JS 的宿主节点:脚本由客户端创建后挂到此处执行 */}
<div ref={hostRef} />
</section>
/>
);
}