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

@@ -129,6 +129,10 @@ function shouldShowTip(host: Element, tip: string): boolean {
/**
* 全站悬停即显提示:监听 [data-tip],替代浏览器原生 title 的延迟气泡。
* 挂在 root layout 即可,业务侧写 data-tip="文案" 并保留 aria-label。
*
* 移动端策略:仅在 (hover: hover) and (pointer: fine) 设备上响应真实鼠标
* 指针与键盘焦点。触屏 tap 既派发合成鼠标事件又会抢占焦点,若照常弹出,
* 会出现「一点击就弹、只能点别处关闭」的体验,因此触屏一律不触发。
*/
export default function TipHost() {
const [tip, setTip] = useState<TipState>(null);
@@ -193,6 +197,15 @@ export default function TipHost() {
}, delay);
};
// 仅在具备真实悬停能力的设备(鼠标/触控板)上启用;纯触屏直接不参与
const hoverMq = window.matchMedia("(hover: hover) and (pointer: fine)");
let canHover = hoverMq.matches;
// 只在键盘导航期间允许聚焦气泡(:focus-visible 语义),点按产生的焦点不弹
let keyboardActive = false;
// 触摸后的短暂窗口,兜底忽略浏览器派发的合成鼠标事件
let lastTouchAt = 0;
const TOUCH_GUARD_MS = 500;
const onOver = (e: Event) => {
const t = e.target;
if (!(t instanceof Element)) return;
@@ -204,8 +217,19 @@ export default function TipHost() {
tryShow(host);
};
const onPointerOver = (e: PointerEvent) => {
if (!canHover || e.pointerType !== "mouse") return;
if (Date.now() - lastTouchAt < TOUCH_GUARD_MS) return;
onOver(e);
};
const onFocusIn = (e: FocusEvent) => {
if (!canHover || !keyboardActive) return;
onOver(e);
};
const onOut = (e: Event) => {
if (!(e instanceof MouseEvent) && !(e instanceof FocusEvent)) return;
if (!(e instanceof PointerEvent) && !(e instanceof FocusEvent)) return;
const related = "relatedTarget" in e ? e.relatedTarget : null;
if (
related instanceof Node &&
@@ -217,25 +241,52 @@ export default function TipHost() {
hide();
};
const onKeyDown = () => {
keyboardActive = true;
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "keyboard") keyboardActive = false;
};
const onTouchStart = () => {
lastTouchAt = Date.now();
keyboardActive = false;
hide();
};
const onHoverMqChange = (e: MediaQueryListEvent) => {
canHover = e.matches;
if (!canHover) hide();
};
const onScrollOrResize = () => {
if (!activeEl.current) return;
tryShow(activeEl.current);
};
// mouseover 冒泡,便于 closest;立即显示,无延时
document.addEventListener("mouseover", onOver);
document.addEventListener("mouseout", onOut);
document.addEventListener("focusin", onOver);
// pointerover 冒泡,便于 closest;仅响应真实鼠标指针,触屏/手写笔不弹
document.addEventListener("pointerover", onPointerOver);
document.addEventListener("pointerout", onOut);
document.addEventListener("focusin", onFocusIn);
document.addEventListener("focusout", onOut);
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("touchstart", onTouchStart, true);
hoverMq.addEventListener("change", onHoverMqChange);
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return () => {
clearPending();
document.removeEventListener("mouseover", onOver);
document.removeEventListener("mouseout", onOut);
document.removeEventListener("focusin", onOver);
document.removeEventListener("pointerover", onPointerOver);
document.removeEventListener("pointerout", onOut);
document.removeEventListener("focusin", onFocusIn);
document.removeEventListener("focusout", onOut);
document.removeEventListener("keydown", onKeyDown, true);
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("touchstart", onTouchStart, true);
hoverMq.removeEventListener("change", onHoverMqChange);
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
};