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

@@ -4,8 +4,35 @@ import { Images, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
/** 书籍封面图库选择弹窗:列出本人媒体库图片,选中后由父组件复制一份作封面素材 */
/** 来源标签映射 */
const SOURCE_LABELS: Record<string, string> = {
library_cover: "书籍封面",
library_content: "书籍内容",
post: "帖子插图",
};
function sourceLabel(source?: string): string {
return SOURCE_LABELS[source || "post"] || source || "帖子插图";
}
/** 按来源分组(library_cover 优先,其次 library_content,最后 post/其他) */
function groupBySource(items: MediaAttachment[]): [string, MediaAttachment[]][] {
const groups = new Map<string, MediaAttachment[]>();
for (const item of items) {
const key = item.source || "post";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
}
// 按优先级排序
const order = ["library_cover", "library_content", "post"];
return Array.from(groups.entries()).sort(
([a], [b]) => order.indexOf(a) - order.indexOf(b)
);
}
/** 书籍封面图库选择弹窗:列出本人媒体库图片,按来源分组 */
export default function LibraryCoverPicker({
open,
onClose,
@@ -45,13 +72,15 @@ export default function LibraryCoverPicker({
};
}, [open]);
const groups = groupBySource(items);
return (
<Modal
open={open}
title="从图库选择封面"
description="选择后会复制一份作为封面素材,之后删除原图不影响封面。"
description="已选作封面的图片会单独分组,再次选择直接复用。"
onClose={onClose}
maxWidthClass="max-w-lg"
maxWidthClass="max-w-2xl"
dismissOnOverlay
>
{loading ? (
@@ -67,28 +96,52 @@ export default function LibraryCoverPicker({
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip="复制一份用作封面"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="图库候选封面" className="w-full h-full object-contain" loading="lazy" />
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
<div className="flex flex-col gap-5 max-h-[60vh] overflow-y-auto">
{groups.map(([source, groupItems]) => (
<section key={source}>
<h4
className="text-[11.5px] font-bold tracking-wide uppercase mb-2 px-0.5"
style={{ color: "var(--ink-3)" }}
>
<Images size={14} />
</span>
</button>
{sourceLabel(source)}
<span className="ml-1.5 font-normal opacity-70">({groupItems.length})</span>
</h4>
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
{groupItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip={
item.source === "library_cover"
? "已作封面,点击直接复用"
: "复制一份用作封面"
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
sizes="(max-width:520px) 23vw, 96px"
alt="图库候选封面"
className="w-full h-full object-contain"
loading="lazy"
/>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
>
<Images size={14} />
</span>
</button>
))}
</div>
</section>
))}
</div>
)}