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,6 +4,7 @@ import (
"errors"
"net/http"
"net/url"
"os"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
@@ -91,7 +92,11 @@ func (h *Handlers) AdminCreateLibraryDoc(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
d, err := h.LibrarySvc.Create(&in)
var creatorID uint
if claims := middleware.CurrentUser(c); claims != nil {
creatorID = claims.ID
}
d, err := h.LibrarySvc.Create(&in, creatorID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -242,6 +247,109 @@ func (h *Handlers) AdminDeleteLibraryFile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ---------- 管理端:导出(迁移 / 备份) ----------
// writeLibraryExport 装载完成后统一以附件形式流式下发 ZIP;
// 注:一旦开始写响应体,中途 IO 错误无法再改成 JSON 错误,只能记录在 c.Errors。
func (h *Handlers) writeLibraryExport(c *gin.Context, exp *service.LibraryExport) {
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(exp.Filename))
c.Header("X-Content-Type-Options", "nosniff")
c.Status(http.StatusOK)
if err := exp.WriteZip(c.Writer); err != nil {
_ = c.Error(err)
}
}
// AdminExportLibraryDoc 导出单本书(元信息 + 章节 + 附件 + 本地封面,ZIP)
func (h *Handlers) AdminExportLibraryDoc(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
exp, err := h.LibrarySvc.BuildBookExport(uint(id))
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
return
}
h.writeLibraryExport(c, exp)
}
// AdminExportAllLibrary 导出全部在用书籍(单 ZIP,library.json 索引)
func (h *Handlers) AdminExportAllLibrary(c *gin.Context) {
exp, err := h.LibrarySvc.BuildAllExport()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
return
}
h.writeLibraryExport(c, exp)
}
// AdminImportLibrary 上传导出 ZIP 恢复书籍(mode=create 默认跳过冲突 / overwrite 覆盖同名)
func (h *Handlers) AdminImportLibrary(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
const overhead = 64 << 10 // multipart 边界开销
limit := int64(service.LibraryImportMaxBytes) + overhead
if c.Request.ContentLength > limit {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
fh, err := c.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大(不能超过 512MB)"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择书库导出的 ZIP 备份包"})
return
}
if fh.Size > service.LibraryImportMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包不能超过 512MB"})
return
}
mode := c.PostForm("mode")
if mode == "" {
mode = service.LibraryImportModeCreate
}
tmpPath, err := saveMultipartToTemp(fh, "library-import-*.zip")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取导入包失败"})
return
}
defer os.Remove(tmpPath)
rep, err := h.LibrarySvc.ImportLibraryZip(tmpPath, mode, claims.ID)
if err != nil {
switch {
case errors.Is(err, service.ErrLibraryImportBadZip),
errors.Is(err, service.ErrLibraryImportFormat),
errors.Is(err, service.ErrLibraryImportVer),
errors.Is(err, service.ErrLibraryImportEmpty),
errors.Is(err, service.ErrLibraryImportMode):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败:" + err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{"report": rep})
}
// ---------- 管理端:章节 ----------
// AdminCreateLibrarySection 新建章节(parent_id 空=章,非空=节)