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

@@ -19,6 +19,7 @@ import (
"time"
"github.com/freefire/jiang13-bbs/model"
webpenc "github.com/gen2brain/webp"
"golang.org/x/image/webp"
"gorm.io/gorm"
)
@@ -29,10 +30,13 @@ const (
AvatarMinDim = 64
AvatarMaxDim = 512
// 帖子插图:允许 JPEG/PNG/WebP,不强制转码
ImageMaxBytes = 5 << 20 // 5 MiB
// 帖子插图:JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
ImageMaxBytes = 5 << 20 // 5 MiB(对原始上传文件生效)
ImageMaxDim = 4096
// JPEG 转 WebP 的有损质量;PNG 走无损,不受此参数影响
ImageWebPQuality = 80
// 站点背景图(前台/后台墙纸)
BackgroundMaxBytes = 8 << 20 // 8 MiB
BackgroundMaxDim = 8192
@@ -190,7 +194,50 @@ func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
return cfg.Width, cfg.Height, nil
}
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
// transcodeImageToWebP 把校验通过的 JPEG/PNG 临时文件解码后转码为 WebP:
// JPEG 走有损 q80,PNG 走无损(保留透明通道与文字锐度)。
// 成功返回新文件名/MIME/大小并清理原临时文件;失败返回 error(调用方保留原格式落盘)。
func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, string, int64, error) {
webpName := strings.TrimSuffix(name, ext) + ".webp"
webpPath := filepath.Join(s.dir, "images", webpName)
webpTmp := webpPath + ".partial"
if s.ops != nil {
release, e := s.ops.BeginTemporary(webpTmp)
if e != nil {
return "", "", 0, e
}
defer release()
}
f, err := os.Open(tmp)
if err != nil {
return "", "", 0, err
}
img, _, err := image.Decode(f) // 仅 JPEG/PNG 会走到这里,解码器已在文件头注册
_ = f.Close()
if err != nil {
return "", "", 0, err
}
var buf bytes.Buffer
if err := webpenc.Encode(&buf, img, webpenc.Options{
Quality: ImageWebPQuality,
Lossless: ext == ".png",
}); err != nil {
return "", "", 0, err
}
if err := os.WriteFile(webpTmp, buf.Bytes(), 0o644); err != nil {
_ = os.Remove(webpTmp)
return "", "", 0, err
}
if err := os.Rename(webpTmp, webpPath); err != nil {
_ = os.Remove(webpTmp)
return "", "", 0, err
}
_ = os.Remove(tmp)
return webpName, "image/webp", int64(buf.Len()), nil
}
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
if src == nil {
return nil, errors.New("文件为空")
@@ -284,33 +331,48 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
_ = os.Remove(tmp)
return nil, errors.New("图片边长不能超过 4096px")
}
if err := os.Rename(tmp, fullPath); err != nil {
_ = os.Remove(tmp)
return nil, err
// JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
storeName, storeMime, storeSize := filename, format.mime, total
storePath := fullPath
if format.mime == "image/jpeg" || format.mime == "image/png" {
name, mime, size, err := s.transcodeImageToWebP(tmp, filename, format.ext)
if err != nil {
log.Printf("[upload] 插图转 WebP 失败,保留原格式 %s: %v", filename, err)
} else {
storeName, storeMime, storeSize = name, mime, size
storePath = filepath.Join(s.dir, "images", name)
}
}
if storePath == fullPath {
if err := os.Rename(tmp, fullPath); err != nil {
_ = os.Remove(tmp)
return nil, err
}
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
URL: "/uploads/images/" + filename,
MIME: format.mime,
Size: int(total),
URL: "/uploads/images/" + storeName,
MIME: storeMime,
Size: int(storeSize),
Width: w,
Height: h,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
id, e := s.ops.StoreFile(storePath, att.MIME, true)
if e != nil {
_ = os.Remove(fullPath)
_ = os.Remove(storePath)
return nil, e
}
if id != "" {
att.URL = "/api/media/" + id
_ = os.Remove(fullPath)
_ = os.Remove(storePath)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(fullPath)
_ = os.Remove(storePath)
return nil, err
}
return att, nil