feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -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) + "》下的评论"
|
||||
|
||||
Reference in New Issue
Block a user