feat: 站点媒体库与移动端底部导航,服务测试补强

- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
2026-09-27 02:35:38 +08:00
parent 734afbc39f
commit 1d862a0dd3
61 changed files with 4757 additions and 1129 deletions

View File

@@ -0,0 +1,388 @@
package service
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
)
// 媒体库(管理后台):盘点站点内全部图片资源。
// 数据源 = uploads 五个分类目录的磁盘扫描 + attachments 表元数据合并:
// - 旧站导入图 / 背景图 / 广告素材 / 品牌 Logo 落盘但无附件记录,只有磁盘扫描能覆盖
// - 附件记录(头像、帖子插图)补充上传者、创建时间等元数据;远程存储对象(/api/media/)仅存在于库中
// 同名(去扩展名)同时存在原图与 WebP 时只展示 WebP,原图 URL 挂在 OriginalURL 供查看。
// 媒体库分类:目录名即分类键,前端 tab 直接使用
var mediaLibraryCategories = []struct {
Key string // 分类键(image/avatar/background/ads/brand)
Dir string // uploads 下的子目录
Name string // 展示名
}{
{"image", "images", "帖子插图"},
{"avatar", "avatars", "用户头像"},
{"background", "backgrounds", "站点背景"},
{"ads", "ads", "广告素材"},
{"brand", "brand", "品牌资源"},
}
// MediaLibraryItem 媒体库条目
type MediaLibraryItem struct {
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
OriginalURL string `json:"original_url,omitempty"` // 同名原图(仅当原图与 WebP 并存)
Category string `json:"category"` // image/avatar/background/ads/brand
CategoryName string `json:"category_name"`
Name string `json:"name"` // 文件名(含扩展名)
MIME string `json:"mime"`
Size int64 `json:"size"`
Width int `json:"width"`
Height int `json:"height"`
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
}
// AdminMediaLibrary 全站图片盘点:磁盘扫描 + 附件元数据合并,按时间倒序。
// 返回条目列表与各分类计数(含全部计数由前端按列表累加)。
func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, error) {
// 附件元数据索引:URL → 记录(联出上传者昵称)
type attMeta struct {
Kind string
URL string
MIME string
Size int
Width int
Height int
Nickname string
Username string
CreatedAt time.Time
}
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").
Joins("LEFT JOIN users ON users.id = attachments.user_id").
Scan(&rows).Error; err != nil {
return nil, nil, err
}
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
}
uploader := r.Nickname
if uploader == "" {
uploader = r.Username
}
cat := r.Kind
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
}
ca := r.CreatedAt
items = append(items, MediaLibraryItem{
URL: r.URL, Category: cat, CategoryName: categoryName(cat),
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
Size: int64(r.Size), Width: r.Width, Height: r.Height,
Uploader: uploader, UploadedAt: &ca,
})
}
// 磁盘扫描五类目录;同名(去扩展名)成组,WebP 优先展示、原图挂 OriginalURL
type group struct {
webp *MediaLibraryItem
original *MediaLibraryItem
}
for _, cat := range mediaLibraryCategories {
dir := filepath.Join(s.dir, cat.Dir)
entries, err := os.ReadDir(dir)
if err != nil {
continue // 目录不存在视为空
}
groups := map[string]*group{}
for _, e := range entries {
if e.IsDir() || strings.HasSuffix(e.Name(), ".partial") {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
default:
continue
}
url := "/uploads/" + cat.Dir + "/" + e.Name()
item := MediaLibraryItem{
URL: url, Category: cat.Key, CategoryName: cat.Name,
Name: e.Name(),
MIME: map[string]string{
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".gif": "image/gif",
}[ext],
}
if info, err := e.Info(); err == nil {
item.Size = info.Size()
mt := info.ModTime()
item.UploadedAt = &mt
}
if m, ok := metaByURL[url]; ok {
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
uploader := m.Nickname
if uploader == "" {
uploader = m.Username
}
ca := m.CreatedAt
item.Uploader = uploader
item.UploadedAt = &ca
if m.Size > 0 {
item.Size = int64(m.Size)
}
if m.Width > 0 {
item.Width, item.Height = m.Width, m.Height
}
}
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
if f, err := os.Open(filepath.Join(dir, e.Name())); err == nil {
w, h, derr := decodeImageSizeReader(f, item.MIME)
_ = f.Close()
if derr == nil {
item.Width, item.Height = w, h
}
}
}
stem := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
g := groups[stem]
if g == nil {
g = &group{}
groups[stem] = g
}
if ext == ".webp" {
g.webp = &item
} else if g.original == nil {
g.original = &item
}
}
for _, g := range groups {
switch {
case g.webp != nil && g.original != nil:
// 原图与 WebP 并存:只展示 WebP,原图作 original_url
g.webp.OriginalURL = g.original.URL
if g.webp.Size == 0 {
g.webp.Size = g.original.Size
}
items = append(items, *g.webp)
case g.webp != nil:
items = append(items, *g.webp)
default:
items = append(items, *g.original)
}
}
}
sort.Slice(items, func(i, j int) bool {
ti, tj := items[i].UploadedAt, items[j].UploadedAt
switch {
case ti == nil && tj == nil:
return items[i].URL < items[j].URL
case ti == nil:
return false
case tj == nil:
return true
}
return ti.After(*tj)
})
counts := map[string]int{}
for _, it := range items {
counts[it.Category]++
}
s.attachMediaSources(items)
return items, counts, nil
}
// attachMediaSources 为条目补来源回链:
// - 头像:users.avatar 精确匹配 → 用户主页
// - 其余:分块 LIKE 扫描帖子/评论正文(展示 URL 与同名原图都参与匹配),帖子优先于评论
//
// 内容 LIKE 无索引,纯顺序扫描,但仅管理端低频调用;分块 OR 控制往返次数。
func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
urlSet := map[string]bool{}
for _, it := range items {
for _, u := range []string{it.URL, it.OriginalURL} {
if strings.HasPrefix(u, "/uploads/") {
urlSet[u] = true
}
}
}
if len(urlSet) == 0 {
return
}
urls := make([]string, 0, len(urlSet))
for u := range urlSet {
urls = append(urls, u)
}
type postRef struct {
id uint
title string
}
postByURL := map[string]postRef{}
type commentRef struct {
id, postID uint
}
commentByURL := map[string]commentRef{}
for i := 0; i < len(urls); i += 40 {
end := i + 40
if end > len(urls) {
end = len(urls)
}
chunk := urls[i:end]
conds := make([]string, len(chunk))
args := make([]any, len(chunk))
for j, u := range chunk {
conds[j] = "content LIKE ?"
args[j] = "%" + u + "%"
}
where := strings.Join(conds, " OR ")
var prows []struct {
ID uint
Title string
Content string
}
if err := s.db.Model(&model.Post{}).Select("id, title, content").
Where(where, args...).Order("id ASC").Limit(600).Scan(&prows).Error; err != nil {
return
}
for _, p := range prows {
for _, u := range chunk {
if strings.Contains(p.Content, u) {
if _, ok := postByURL[u]; !ok {
postByURL[u] = postRef{id: p.ID, title: p.Title}
}
}
}
}
var crows []struct {
ID uint
PostID uint
Content string
}
if err := s.db.Model(&model.Comment{}).Select("id, post_id, content").
Where(where, args...).Order("id ASC").Limit(600).Scan(&crows).Error; err != nil {
return
}
for _, cm := range crows {
for _, u := range chunk {
if strings.Contains(cm.Content, u) {
if _, ok := commentByURL[u]; !ok {
commentByURL[u] = commentRef{id: cm.ID, postID: cm.PostID}
}
}
}
}
}
// 评论来源要带宿主帖子标题,一次性补齐
postTitle := func(id uint) string {
var p model.Post
if err := s.db.Select("id, title").First(&p, id).Error; err != nil {
return ""
}
return p.Title
}
commentPostTitle := map[uint]string{}
for _, cm := range commentByURL {
if _, ok := commentPostTitle[cm.postID]; !ok {
commentPostTitle[cm.postID] = postTitle(cm.postID)
}
}
// 头像使用者:users.avatar 精确匹配(同一头像多人使用时取最早的用户)
userByAvatar := map[string]struct {
ID uint
Nickname, Username string
}{}
var urows []struct {
ID uint
Nickname string
Username string
Avatar string
}
if err := s.db.Model(&model.User{}).Select("id, nickname, username, avatar").
Where("avatar IN ?", urls).Order("id ASC").Find(&urows).Error; err == nil {
for _, u := range urows {
if _, ok := userByAvatar[u.Avatar]; !ok {
userByAvatar[u.Avatar] = struct {
ID uint
Nickname, Username string
}{ID: u.ID, Nickname: u.Nickname, Username: u.Username}
}
}
}
truncate := func(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
postLabel := func(p postRef) string {
title := p.title
if title == "" {
title = fmt.Sprintf("#%d", p.id)
}
return "帖子《" + truncate(title, 40) + "》"
}
for i := range items {
it := &items[i]
// 头像使用者优先(头像正文引用场景极少)
if u, ok := userByAvatar[it.URL]; ok {
name := u.Nickname
if name == "" {
name = u.Username
}
it.SourceURL = fmt.Sprintf("/u/%d", u.ID)
it.SourceLabel = "@" + name + " 的头像"
continue
}
for _, u := range []string{it.URL, it.OriginalURL} {
if u == "" || it.SourceURL != "" {
continue
}
if p, ok := postByURL[u]; ok {
it.SourceURL = fmt.Sprintf("/post/%d", p.id)
it.SourceLabel = postLabel(p)
break
}
if cm, ok := commentByURL[u]; ok {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id)
title := commentPostTitle[cm.postID]
if title != "" {
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
} else {
it.SourceLabel = "帖子评论"
}
break
}
}
}
}