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

@@ -30,6 +30,27 @@ var mediaLibraryCategories = []struct {
{"brand", "brand", "品牌资源"},
}
// 衍生分类:不对应 uploads 子目录,按附件 source 归类
const (
mediaCategoryBook = "book"
mediaCategoryBookName = "书籍"
)
// mediaCategoryForAttachment 附件在媒体库中的展示分类:
// 书库封面 / 正文插图(source=library_cover|library_content)统一归“书籍”,
// 其余按 kind 对应磁盘目录分类。
func mediaCategoryForAttachment(kind, source string) (key, name string) {
if source == model.AttachmentSourceLibraryCover || source == model.AttachmentSourceLibraryContent {
return mediaCategoryBook, mediaCategoryBookName
}
for _, cat := range mediaLibraryCategories {
if cat.Key == kind {
return cat.Key, cat.Name
}
}
return kind, kind
}
// MediaLibraryItem 媒体库条目
type MediaLibraryItem struct {
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
@@ -53,6 +74,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
// 附件元数据索引:URL → 记录(联出上传者昵称)
type attMeta struct {
Kind string
Source string
URL string
MIME string
Size int
@@ -64,7 +86,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
}
var rows []attMeta
if err := s.db.Table("attachments").
Select("attachments.kind, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
Select("attachments.kind, attachments.source, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
Joins("LEFT JOIN users ON users.id = attachments.user_id").
Scan(&rows).Error; err != nil {
return nil, nil, err
@@ -72,30 +94,22 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
metaByURL := make(map[string]attMeta, len(rows))
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
items := make([]MediaLibraryItem, 0, len(rows))
categoryName := func(key string) string {
for _, cat := range mediaLibraryCategories {
if cat.Key == key {
return cat.Name
}
}
return key
}
for _, r := range rows {
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
if !strings.HasPrefix(r.URL, "/api/media/") {
continue
}
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
}
uploader := r.Nickname
if uploader == "" {
uploader = r.Username
}
cat := r.Kind
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
}
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
ca := r.CreatedAt
items = append(items, MediaLibraryItem{
URL: r.URL, Category: cat, CategoryName: categoryName(cat),
URL: r.URL, Category: catKey, CategoryName: catName,
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
Size: int64(r.Size), Width: r.Width, Height: r.Height,
Uploader: uploader, UploadedAt: &ca,
@@ -153,6 +167,11 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
if m.Width > 0 {
item.Width, item.Height = m.Width, m.Height
}
// 书库封面 / 正文插图改归“书籍”分类
if m.Source == model.AttachmentSourceLibraryCover || m.Source == model.AttachmentSourceLibraryContent {
item.Category = mediaCategoryBook
item.CategoryName = mediaCategoryBookName
}
}
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
@@ -243,6 +262,8 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
postByURL := map[string]postRef{}
type commentRef struct {
id, postID uint
floor uint
isRoot bool
}
commentByURL := map[string]commentRef{}
@@ -299,6 +320,19 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
}
}
// 评论锚点改用所属帖内相对楼层号(#comment-{floor},楼中楼追加 -r{id})
commentIDs := make([]uint, 0, len(commentByURL))
for _, cm := range commentByURL {
commentIDs = append(commentIDs, cm.id)
}
commentAnchors := CommentAnchors(s.db, commentIDs)
for u, cm := range commentByURL {
if a, ok := commentAnchors[cm.id]; ok {
cm.floor, cm.isRoot = a.Floor, a.IsRoot
commentByURL[u] = cm
}
}
// 评论来源要带宿主帖子标题,一次性补齐
postTitle := func(id uint) string {
var p model.Post
@@ -374,7 +408,15 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
break
}
if cm, ok := commentByURL[u]; ok {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id)
if cm.floor > 0 {
if cm.isRoot {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.floor)
} else {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d-r%d", cm.postID, cm.floor, cm.id)
}
} else {
it.SourceURL = fmt.Sprintf("/post/%d", cm.postID)
}
title := commentPostTitle[cm.postID]
if title != "" {
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"