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

@@ -10,6 +10,7 @@ import (
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
@@ -102,7 +103,7 @@ func (h *Handlers) UploadImage(c *gin.Context) {
}
defer f.Close()
att, err := h.Upload.SaveImage(claims.ID, f)
att, err := h.Upload.SaveImage(claims.ID, f, c.PostForm("source"))
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
@@ -314,7 +315,9 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": url})
}
// UploadLibraryCoverFromMedia 把本人媒体库图片复制一份作书籍封面素材,只返回 URL,不绑定条目
// UploadLibraryCoverFromMedia 把本人媒体库图片用作书籍封面素材。
// 若该图已是封面类型(source=library_cover),直接复用原记录,不再复制;
// 否则复制一份并标记为封面类型(与原图解耦,删除原图不影响封面)。
func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
var req struct {
AttachmentID uint `json:"attachment_id"`
@@ -328,7 +331,14 @@ func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID)
// 已是封面类型的图直接复用,避免重复复制产生冗余记录
if existing, err := h.Upload.FindMediaBySource(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover); err == nil {
c.JSON(http.StatusOK, gin.H{"url": existing.URL, "attachment": existing})
return
}
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -404,6 +414,23 @@ func (h *Handlers) MediaLibraryThumb(c *gin.Context) {
c.Data(http.StatusOK, "image/webp", data)
}
// ImageVariant 公开图片变体(GET /api/img?u=<源图URL>&w=<白名单宽度>):
// 供全站 srcset 消费,源内容不可变 → immutable 长缓存;失败 404 由前端 onError 兜底
func (h *Handlers) ImageVariant(c *gin.Context) {
w, err := strconv.Atoi(c.Query("w"))
if err != nil || !service.IsVariantWidth(w) {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的尺寸"})
return
}
data, m, err := h.Upload.Variant(c.Query("u"), w)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "图片不可用"})
return
}
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Data(http.StatusOK, m, data)
}
func brandTooLarge(slot string) string {
if slot == service.BrandSlotFavicon {
return "Favicon 不能超过 512KB"