feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user