2 Commits
v0.2.8 ... main

Author SHA1 Message Date
5598e1cbb7 chore: bump version to 0.2.9 2026-10-03 02:40:45 +08:00
46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00
33 changed files with 1243 additions and 229 deletions

View File

@@ -1 +1 @@
0.2.8
0.2.9

View File

@@ -262,6 +262,10 @@ func (h *Handlers) SendEmailCode(c *gin.Context) {
return
}
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
if errors.Is(e, service.ErrEmailRegistered) {
c.JSON(http.StatusBadRequest, gin.H{"error": e.Error()})
return
}
if !h.quotaResponse(c, wait, e) {
return
}

View File

@@ -57,6 +57,7 @@ type updateSettingsRequest struct {
BrandMark *string `json:"brand_mark"`
BrandLogoSize *string `json:"brand_logo_size"`
BrandLogoFit *string `json:"brand_logo_fit"`
BrandSloganVisible *bool `json:"brand_slogan_visible"`
FooterLinks *[]service.FooterLink `json:"footer_links"`
PointsCheckinBase *int `json:"points_checkin_base"`
@@ -120,6 +121,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"brand_mark": saved.BrandMark,
"brand_logo_size": saved.BrandLogoSize,
"brand_logo_fit": saved.BrandLogoFit,
"brand_slogan_visible": saved.BrandSloganVisible,
"footer_links": saved.FooterLinks,
"points_checkin_base": saved.PointsCheckinBase,
@@ -160,6 +162,7 @@ func (req *updateSettingsRequest) hasAny() bool {
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
req.BrandSloganVisible != nil ||
req.FooterLinks != nil ||
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
@@ -540,6 +543,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.BrandSloganVisible != nil {
if err := h.Setting.SetBrandSloganVisible(*req.BrandSloganVisible); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.FooterLinks != nil {
if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {

View File

@@ -56,7 +56,7 @@ func InitDB(dsn string) error {
}
if err := db.AutoMigrate(
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, &Blob{},
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},

View File

@@ -362,12 +362,13 @@ type PostContentUnlock struct {
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
type PostAttachment struct {
ObjectID string `gorm:"size:64;index" json:"-"`
ObjectID string `gorm:"size:96;index" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
UserID uint `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名(blob 去重后即内容 hash)
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"`
PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
@@ -666,13 +667,14 @@ const (
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
)
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
// Attachment 用户上传的附件(头像与帖子插图等公开图片)
type Attachment struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
Source string `gorm:"size:24;not null;default:'post'" json:"source"`
URL string `gorm:"size:512;not null" json:"url"`
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
Width int `gorm:"not null;default:0" json:"width"`
@@ -680,6 +682,20 @@ type Attachment struct {
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// Blob 内容寻址物理文件:对【最终存储字节】算 SHA-256,同内容多次上传共享一份底层存储,
// 业务行(Attachment/PostAttachment 及未来的聊天文件消息)只引用 hash,按引用计数回收。
// 公开(帖子插图/头像)与私有(付费附件、未来私聊文件)是两个互不可见的命名空间:
// 同字节也各自存一份,避免私有文件借公开链接(/api/media/<id>)被越权读取。
type Blob struct {
Hash string `gorm:"primaryKey;size:64" json:"-"` // 最终存储字节的 SHA-256 hex(图片为转码后的字节)
Public bool `gorm:"primaryKey" json:"-"`
RefCount int64 `gorm:"not null;default:0" json:"-"`
Size int `gorm:"not null;default:0" json:"-"`
MIME string `gorm:"size:128;not null;default:''" json:"-"`
ObjectID string `gorm:"size:96;not null;default:''" json:"-"` // S3 后端的 StoredObject ID;本地后端为空
CreatedAt time.Time `json:"-"`
}
// UserBoard 板块管理员的板块授权(多对多;仅 role=board_admin 的行生效)
type UserBoard struct {
ID uint `gorm:"primaryKey" json:"id"`

View File

@@ -51,7 +51,7 @@ type EmailChallenge struct {
// Every remote object pins the immutable configuration revision that wrote it.
type StoredObject struct {
ID string `gorm:"primaryKey;size:64" json:"id"`
ID string `gorm:"primaryKey;size:96" json:"id"`
ConfigName string `gorm:"size:64;index" json:"-"`
Key string `gorm:"size:512" json:"-"`
MIME string `gorm:"size:128" json:"-"`

View File

@@ -107,10 +107,13 @@ var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
// 邮箱非空时全库唯一(忽略大小写):后期支持邮箱登录,一邮箱只能对应一个账号
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
return nil, err
}
// 邮箱统一小写归一化,避免大小写变体重复注册
email = strings.ToLower(strings.TrimSpace(email))
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -130,6 +133,16 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
if count > 0 {
return errors.New("用户名已被使用")
}
// 邮箱唯一(忽略大小写,兜底防绕过;入口发码处已拦一次)
if email != "" {
var emailCount int64
if err := tx.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&emailCount).Error; err != nil {
return err
}
if emailCount > 0 {
return errors.New("该邮箱已被注册")
}
}
var n int64
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
return err
@@ -681,14 +694,14 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature stri
if nickCount > 0 {
return nil, errors.New("该昵称已被使用")
}
email = strings.TrimSpace(email)
email = strings.ToLower(strings.TrimSpace(email))
if email != "" {
if !strings.Contains(email, "@") || len(email) > 128 {
return nil, errors.New("邮箱格式不正确")
}
// 邮箱唯一性校验(排除自身)
// 邮箱唯一性校验(忽略大小写,排除自身),与注册口径一致
var count int64
s.db.Model(&model.User{}).Where("email = ? AND id <> ?", email, userID).Count(&count)
s.db.Model(&model.User{}).Where("LOWER(email) = ? AND id <> ?", email, userID).Count(&count)
if count > 0 {
return nil, errors.New("该邮箱已被使用")
}

View File

@@ -0,0 +1,223 @@
package service
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"os"
"path/filepath"
"testing"
"github.com/freefire/jiang13-bbs/model"
webpenc "github.com/gen2brain/webp"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newBlobTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.Blob{}, &model.Attachment{}, &model.PostAttachment{},
&model.User{}, &model.Post{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func webpFixtureBytes() []byte {
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
src.Set(0, 0, color.RGBA{10, 20, 30, 255})
var buf bytes.Buffer
_ = webpenc.Encode(&buf, src, webpenc.Options{Quality: ImageWebPQuality})
return buf.Bytes()
}
func TestSaveImageBlobDedupAndRefcount(t *testing.T) {
db := newBlobTestDB(t)
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
data := webpFixtureBytes()
att1, err := s.SaveImage(1, bytes.NewReader(data), "post")
if err != nil {
t.Fatalf("SaveImage 1: %v", err)
}
att2, err := s.SaveImage(2, bytes.NewReader(data), "post")
if err != nil {
t.Fatalf("SaveImage 2: %v", err)
}
if att1.URL != att2.URL || att1.BlobHash == "" || att1.BlobHash != att2.BlobHash {
t.Fatalf("identical content should share url/hash: %q vs %q", att1.URL, att2.URL)
}
var blob model.Blob
if err := db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true).Error; err != nil {
t.Fatalf("load blob: %v", err)
}
if blob.RefCount != 2 {
t.Fatalf("refcount want 2, got %d", blob.RefCount)
}
blobPath := filepath.Join(dir, filepath.FromSlash(att1.URL[len("/uploads/"):]))
if fi, err := os.Stat(blobPath); err != nil || fi.IsDir() {
t.Fatalf("shared physical file missing: %v", err)
}
// 删一个引用:文件保留
if err := s.DeleteAttachment(1, att1.ID); err != nil {
t.Fatalf("DeleteAttachment 1: %v", err)
}
db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true)
if blob.RefCount != 1 {
t.Fatalf("refcount want 1, got %d", blob.RefCount)
}
if _, err := os.Stat(blobPath); err != nil {
t.Fatalf("blob file should survive while referenced: %v", err)
}
// 最后一个引用删除:文件与 blob 行一起回收
if err := s.DeleteAttachment(2, att2.ID); err != nil {
t.Fatalf("DeleteAttachment 2: %v", err)
}
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
t.Fatalf("blob file should be removed after last release, stat err=%v", err)
}
var n int64
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att1.BlobHash, true).Count(&n)
if n != 0 {
t.Fatalf("blob row should be gone, count=%d", n)
}
}
func TestSaveImageTranscodedJPEGDedup(t *testing.T) {
db := newBlobTestDB(t)
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
src.Set(1, 2, color.RGBA{200, 100, 50, 255})
var in bytes.Buffer
if err := jpeg.Encode(&in, src, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
att1, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage 1: %v", err)
}
att2, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage 2: %v", err)
}
// hash 必须对转码后的字节计算:同源 JPEG 两次上传应命中同一个 WebP blob
if att1.URL != att2.URL || att1.BlobHash != att2.BlobHash {
t.Fatalf("transcoded JPEG should dedup: %q(%s) vs %q(%s)", att1.URL, att1.BlobHash, att2.URL, att2.BlobHash)
}
}
func TestSaveDraftFileBlobDedupAndRefcount(t *testing.T) {
db := newBlobTestDB(t)
root := t.TempDir()
s := NewPostFileService(db, root)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
data := []byte("paid attachment content v1")
a1, err := s.SaveDraftFile(1, "report.pdf", bytes.NewReader(data), 50)
if err != nil {
t.Fatalf("SaveDraftFile 1: %v", err)
}
a2, err := s.SaveDraftFile(2, "another-name.pdf", bytes.NewReader(data), 0)
if err != nil {
t.Fatalf("SaveDraftFile 2: %v", err)
}
if a1.BlobHash == "" || a1.BlobHash != a2.BlobHash || a1.StoredName != a2.StoredName {
t.Fatalf("same bytes should share blob: %+v vs %+v", a1, a2)
}
// 原始文件名仍各自保留
if a1.Name != "report.pdf" || a2.Name != "another-name.pdf" {
t.Fatalf("original names not preserved: %q / %q", a1.Name, a2.Name)
}
blobPath := s.FilePath(a1)
if _, err := os.Stat(blobPath); err != nil {
t.Fatalf("shared private blob missing: %v", err)
}
var blob model.Blob
if err := db.First(&blob, "hash = ? AND public = ?", a1.BlobHash, false).Error; err != nil {
t.Fatalf("load private blob: %v", err)
}
if blob.RefCount != 2 {
t.Fatalf("refcount want 2, got %d", blob.RefCount)
}
if err := s.DeleteOwn(1, a1.ID); err != nil {
t.Fatalf("DeleteOwn 1: %v", err)
}
if _, err := os.Stat(blobPath); err != nil {
t.Fatalf("private blob should survive with one ref: %v", err)
}
if err := s.DeleteOwn(2, a2.ID); err != nil {
t.Fatalf("DeleteOwn 2: %v", err)
}
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
t.Fatalf("private blob should be removed after last release, stat err=%v", err)
}
}
func TestBlobPublicPrivateVisibilityIsolation(t *testing.T) {
db := newBlobTestDB(t)
root := t.TempDir()
img := NewUploadService(db, filepath.Join(root, "uploads"))
pf := NewPostFileService(db, filepath.Join(root, "private"))
if err := img.EnsureDir(); err != nil {
t.Fatalf("ensure upload dir: %v", err)
}
if err := pf.EnsureDir(); err != nil {
t.Fatalf("ensure private dir: %v", err)
}
data := webpFixtureBytes()
att, err := img.SaveImage(1, bytes.NewReader(data), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
file, err := pf.SaveDraftFile(1, "x.webp", bytes.NewReader(data), 0)
if err != nil {
t.Fatalf("SaveDraftFile: %v", err)
}
if att.BlobHash != file.BlobHash {
t.Fatalf("test premise: same bytes should produce same hash")
}
// 同字节在公开/私有命名空间各持一条 blob,互不复用,防止越权读取
var pubs, privs int64
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, true).Count(&pubs)
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, false).Count(&privs)
if pubs != 1 || privs != 1 {
t.Fatalf("want separate public/private blobs, pubs=%d privs=%d", pubs, privs)
}
publicPath := filepath.Join(root, "uploads", filepath.FromSlash(att.URL[len("/uploads/"):]))
privatePath := pf.FilePath(file)
if publicPath == privatePath {
t.Fatal("public and private blob must not share a path")
}
if _, err := os.Stat(publicPath); err != nil {
t.Fatalf("public blob missing: %v", err)
}
if _, err := os.Stat(privatePath); err != nil {
t.Fatalf("private blob missing: %v", err)
}
}

View File

@@ -0,0 +1,132 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 内容寻址存储(content-addressed storage):
// 物理文件按【最终存储字节】的 SHA-256 命名,业务行只引用 hash 并各持一份引用计数;
// 同内容重复上传不再重复落盘/传输,最后一个引用删除时才回收物理文件。
// 公开与私有是独立命名空间(见 model.Blob 注释)。
// sha256Hex 计算字节的 SHA-256 hex
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// hashFile 计算文件内容的 SHA-256 hex,整文件流式读取不进内存
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// acquireBlobRef 登记一次 blob 引用:行不存在则创建(计数=1),已存在则计数+1。
// 并发下靠 (hash, public) 主键冲突重试,保证不丢计数。
func acquireBlobRef(db *gorm.DB, hash string, public bool, size int, mimeType, objectID string) error {
for {
var b model.Blob
err := db.Where("hash = ? AND public = ?", hash, public).First(&b).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
b = model.Blob{
Hash: hash,
Public: public,
RefCount: 1,
Size: size,
MIME: mimeType,
ObjectID: objectID,
}
if err := db.Create(&b).Error; err != nil {
if isUniqueConflict(err) {
continue // 并发首建:改为走计数+1
}
return err
}
return nil
}
if err != nil {
return err
}
return db.Model(&model.Blob{}).
Where("hash = ? AND public = ?", hash, public).
UpdateColumn("ref_count", gorm.Expr("ref_count + 1")).Error
}
}
// releaseBlobRef 释放一次 blob 引用。计数归零时删行并返回 last=true 与该行快照
// (快照里的 ObjectID/Hash 供调用方回收物理对象)。行不存在视为幂等成功。
func releaseBlobRef(db *gorm.DB, hash string, public bool) (model.Blob, bool, error) {
var removed model.Blob
var last bool
err := db.Transaction(func(tx *gorm.DB) error {
var b model.Blob
// 行锁:postgres 下 FOR UPDATE 串行化同 blob 的并发释放;sqlite 写事务天然串行
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&b, "hash = ? AND public = ?", hash, public).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if b.RefCount <= 1 {
result := tx.Where("hash = ? AND public = ? AND ref_count <= 1", hash, public).Delete(&model.Blob{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
removed = b
last = true
}
return nil
}
return tx.Model(&model.Blob{}).
Where("hash = ? AND public = ?", hash, public).
UpdateColumn("ref_count", b.RefCount-1).Error
})
if err != nil {
return model.Blob{}, false, err
}
return removed, last, nil
}
// placeLocalBlob 把临时文件原子放到内容寻址目标路径:
// 目标已存在说明同内容已落盘(字节必然一致),直接丢弃源文件;否则整体 rename。
// src 与 dst 同一路径时不做操作。
func placeLocalBlob(src, dst string) error {
if abs, err := filepath.Abs(src); err == nil {
if dstAbs, err := filepath.Abs(dst); err == nil && abs == dstAbs {
return nil
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if _, err := os.Stat(dst); err == nil {
if src != dst {
_ = os.Remove(src)
}
return nil
}
if err := os.Rename(src, dst); err != nil {
_ = os.Remove(src)
return err
}
return nil
}

View File

@@ -9,16 +9,17 @@ import (
// 站点品牌与页脚(公开设置,缺省为空)
const (
SettingKeySiteWordmark = "site_wordmark"
SettingKeySiteSlogan = "site_slogan"
SettingKeySiteKeywords = "site_keywords"
SettingKeyLogoLightURL = "logo_light_url"
SettingKeyLogoDarkURL = "logo_dark_url"
SettingKeyFaviconURL = "favicon_url"
SettingKeyFooterLinks = "footer_links"
SettingKeyBrandMark = "brand_mark"
SettingKeyBrandLogoSize = "brand_logo_size"
SettingKeyBrandLogoFit = "brand_logo_fit"
SettingKeySiteWordmark = "site_wordmark"
SettingKeySiteSlogan = "site_slogan"
SettingKeySiteKeywords = "site_keywords"
SettingKeyLogoLightURL = "logo_light_url"
SettingKeyLogoDarkURL = "logo_dark_url"
SettingKeyFaviconURL = "favicon_url"
SettingKeyFooterLinks = "footer_links"
SettingKeyBrandMark = "brand_mark"
SettingKeyBrandLogoSize = "brand_logo_size"
SettingKeyBrandLogoFit = "brand_logo_fit"
SettingKeyBrandSloganVisible = "brand_slogan_visible"
BrandSlotLight = "logo_light"
BrandSlotDark = "logo_dark"
@@ -43,6 +44,8 @@ const (
BrandLogoSize3x1 = "3x1"
BrandLogoSize4x1 = "4x1"
BrandLogoSize169 = "16x9"
// BrandLogoSizeAuto 原图比例:自由裁剪,页眉图槽按图片原始比例自适应宽度
BrandLogoSizeAuto = "auto"
BrandLogoFitCover = "cover"
BrandLogoFitContain = "contain"
@@ -114,7 +117,7 @@ func NormalizeBrandLogoSize(raw string) (string, bool) {
switch strings.TrimSpace(raw) {
case "", BrandLogoSizeSQ:
return BrandLogoSizeSQ, true
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169:
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169, BrandLogoSizeAuto:
return strings.TrimSpace(raw), true
default:
return "", false
@@ -367,6 +370,15 @@ func (s *SettingService) fillBrand(out *PublicSiteSettings) error {
out.BrandLogoFit = f
}
}
sv, found, err := s.getValue(SettingKeyBrandSloganVisible)
if err != nil {
return err
}
if found {
out.BrandSloganVisible = sv == "true"
} else {
out.BrandSloganVisible = true
}
rawLinks, found, err := s.getValue(SettingKeyFooterLinks)
if err != nil {
return err
@@ -483,6 +495,14 @@ func (s *SettingService) SetBrandLogoFit(raw string) error {
return s.putValue(SettingKeyBrandLogoFit, v)
}
// SetBrandSloganVisible 控制标语是否在页眉显示;true 为缺省值,存库时删 key 以保持精简。
func (s *SettingService) SetBrandSloganVisible(visible bool) error {
if visible {
return s.deleteKey(SettingKeyBrandSloganVisible)
}
return s.putValue(SettingKeyBrandSloganVisible, "false")
}
func (s *SettingService) setBrandURL(key, slot, url string) error {
u, ok := NormalizeBrandURL(slot, url)
if !ok {

View File

@@ -64,6 +64,7 @@ type MediaLibraryItem struct {
Height int `json:"height"`
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
RefCount int `json:"ref_count,omitempty"` // 引用计数(>1 表示内容相同的重复上传共享此文件)
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
}
@@ -91,13 +92,29 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
Scan(&rows).Error; err != nil {
return nil, nil, err
}
metaByURL := make(map[string]attMeta, len(rows))
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
items := make([]MediaLibraryItem, 0, len(rows))
// 同 URL 多条记录(内容寻址去重后,重复上传共享同一文件):
// 上传者/时间取最早一条,计数即该文件的引用次数
type urlMeta struct {
meta attMeta
count int
}
agg := make(map[string]*urlMeta, len(rows))
for _, r := range rows {
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
if !strings.HasPrefix(r.URL, "/api/media/") {
continue
if a, ok := agg[r.URL]; ok {
a.count++
if r.CreatedAt.Before(a.meta.CreatedAt) {
a.meta = r
}
} else {
agg[r.URL] = &urlMeta{meta: r, count: 1}
}
}
// 远程存储对象(/api/media/)不在磁盘上,按 URL 去重后直接作为条目加入
items := make([]MediaLibraryItem, 0, len(agg))
for url, a := range agg {
r := a.meta
if !strings.HasPrefix(url, "/api/media/") {
continue // 本地文件在磁盘扫描时按 URL 合并元数据
}
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
@@ -109,10 +126,10 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
ca := r.CreatedAt
items = append(items, MediaLibraryItem{
URL: r.URL, Category: catKey, CategoryName: catName,
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
URL: url, Category: catKey, CategoryName: catName,
Name: strings.TrimPrefix(url, "/api/media/"), MIME: r.MIME,
Size: int64(r.Size), Width: r.Width, Height: r.Height,
Uploader: uploader, UploadedAt: &ca,
Uploader: uploader, UploadedAt: &ca, RefCount: a.count,
})
}
@@ -152,8 +169,9 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
mt := info.ModTime()
item.UploadedAt = &mt
}
if m, ok := metaByURL[url]; ok {
if a, ok := agg[url]; ok {
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
m := a.meta
uploader := m.Nickname
if uploader == "" {
uploader = m.Username
@@ -161,6 +179,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
ca := m.CreatedAt
item.Uploader = uploader
item.UploadedAt = &ca
item.RefCount = a.count
if m.Size > 0 {
item.Size = int64(m.Size)
}

View File

@@ -25,7 +25,7 @@ func newMediaLibraryService(t *testing.T) (*UploadService, string) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Attachment{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
@@ -315,6 +315,82 @@ func TestAdminMediaLibraryBookCategory(t *testing.T) {
}
}
func TestAdminMediaLibraryDedupAggregation(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "images/dup.webp", []byte("fake-webp-bytes"))
mkUser := func(username, nickname string) model.User {
if err := s.db.Create(&model.User{Username: username, Nickname: nickname}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var u model.User
if err := s.db.Where("username = ?", username).First(&u).Error; err != nil {
t.Fatalf("load user: %v", err)
}
return u
}
alice := mkUser("alice", "爱丽丝")
bob := mkUser("bob", "阿Bob")
early := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
late := early.Add(2 * time.Hour)
// 同一张图被两人先后上传(内容寻址去重后共享同一文件/对象)
mkAtt := func(u model.User, url string, at time.Time) {
if err := s.db.Create(&model.Attachment{
UserID: u.ID, Kind: model.AttachmentKindImage,
URL: url, MIME: "image/webp",
Size: 100, Width: 10, Height: 10, CreatedAt: at,
}).Error; err != nil {
t.Fatalf("create attachment %s: %v", url, err)
}
}
mkAtt(alice, "/uploads/images/dup.webp", early)
mkAtt(bob, "/uploads/images/dup.webp", late)
mkAtt(bob, "/api/media/rdup", early)
mkAtt(alice, "/api/media/rdup", late)
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
countByURL := map[string]int{}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
countByURL[it.URL]++
byURL[it.URL] = it
}
// 本地去重文件:单个条目,上传者/时间取最早上传的爱丽丝,引用计数 2
local, ok := byURL["/uploads/images/dup.webp"]
if !ok {
t.Fatal("dedup local file missing")
}
if countByURL["/uploads/images/dup.webp"] != 1 {
t.Fatal("dedup local file should appear exactly once")
}
if local.Uploader != "爱丽丝" || local.RefCount != 2 {
t.Fatalf("local dedup meta wrong: %+v", local)
}
if local.UploadedAt == nil || !local.UploadedAt.Equal(early) {
t.Fatalf("local dedup time should be earliest upload, got %v", local.UploadedAt)
}
// 远程对象同 URL 两条记录:也只出一个条目,取最早的 bob
remote, ok := byURL["/api/media/rdup"]
if !ok {
t.Fatal("dedup remote object missing")
}
if countByURL["/api/media/rdup"] != 1 {
t.Fatal("dedup remote object should appear exactly once")
}
if remote.Uploader != "阿Bob" || remote.RefCount != 2 {
t.Fatalf("remote dedup meta wrong: %+v", remote)
}
}
func TestMediaThumb(t *testing.T) {
s, dir := newMediaLibraryService(t)

View File

@@ -24,6 +24,9 @@ import (
type mailPayload struct{ To, Subject, Body string }
// ErrEmailRegistered 注册发码时邮箱已被占用,handler 据此返回 400 业务提示
var ErrEmailRegistered = errors.New("该邮箱已被注册,请直接登录")
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
password, e := o.open(c.Password, "mail:password")
if e != nil {
@@ -392,6 +395,16 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
return 0, nil
}
}
if purpose == "register" {
// 入口拦截:邮箱已被占用则不发码,省一次邮件资源,也给用户明确提示
var n int64
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
return 0, e
}
if n > 0 {
return 0, ErrEmailRegistered
}
}
code := randomCode()
hash := counterKey(email + ":" + purpose + ":" + code)
e = o.db.Transaction(func(tx *gorm.DB) error {

View File

@@ -16,6 +16,8 @@ import (
"path/filepath"
"strings"
"time"
"gorm.io/gorm"
)
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
@@ -137,6 +139,65 @@ func (o *Operations) StoreFile(path, mimeType string, public bool) (string, erro
}
return id, nil
}
// blobObjectID 内容寻址对象的稳定 ID:公开/私有加不同前缀,同字节也不共享可见性。
func blobObjectID(hash string, public bool) string {
if public {
return "pub_" + hash
}
return "prv_" + hash
}
// StoreFileHashed 按内容 hash 幂等上传:同一 (hash, public) 只存一份,重复上传跳过传输。
// 本地后端返回空 ID(文件留在本地 blob 目录,由调用方做引用计数)。
func (o *Operations) StoreFileHashed(path, mimeType string, public bool, hash string) (string, error) {
v, version, e := o.read(o.db, "storage")
if e != nil {
return "", errors.New("读取存储配置失败")
}
c := *v.(*StorageConfig)
if c.Backend == "local" {
return "", nil
}
id := blobObjectID(hash, public)
// hash 相同字节必然相同:对象已存在时直接复用,省掉整次上传
var existing model.StoredObject
if e := o.db.First(&existing, "id = ?", id).Error; e == nil {
return id, nil
} else if !errors.Is(e, gorm.ErrRecordNotFound) {
return "", e
}
client, e := o.s3(c)
if e != nil {
return "", e
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
f, e := os.Open(path)
if e != nil {
return "", e
}
stat, e := f.Stat()
if e != nil {
_ = f.Close()
return "", e
}
key := c.Prefix + "objects/" + id
if _, e = client.PutObject(ctx, c.Bucket, key, f, stat.Size(), minio.PutObjectOptions{ContentType: mimeType, DisableMultipart: true}); e != nil {
_ = f.Close()
return "", errors.New("上传到 S3 失败,当前目标未自动切换")
}
_ = f.Close()
obj := model.StoredObject{ID: id, ConfigName: fmt.Sprintf("storage-%d", version), Key: key, MIME: mimeType, Public: public}
if e = o.db.Create(&obj).Error; e != nil {
if isUniqueConflict(e) { // 并发:另一请求已写入同内容对象,直接复用
return id, nil
}
_ = client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
return "", errors.New("保存文件记录失败")
}
return id, nil
}
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
var obj model.StoredObject
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {

View File

@@ -2,10 +2,12 @@ package service
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"unicode/utf8"
@@ -145,8 +147,9 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
if err != nil {
return nil, err
}
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」;落盘同时计算内容 hash
hasher := sha256.New()
written, copyErr := io.Copy(io.MultiWriter(f, hasher), io.LimitReader(src, maxB+1))
_ = f.Close()
if copyErr != nil {
_ = os.Remove(tmp)
@@ -167,28 +170,45 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
return nil, err
}
att := &model.PostAttachment{
PostID: 0,
UserID: userID,
Name: name,
StoredName: stored,
MIME: mimeType,
Size: int(written),
PricePoints: pricePoints,
}
// 内容寻址:私有 blob 以 hash 命名(不带扩展名,下载时以 att.MIME + 原文件名下发)
hash := hex.EncodeToString(hasher.Sum(nil))
final := absPath(s.dir, hash)
var objectID string
if s.ops != nil {
id, e := s.ops.StoreFile(full, att.MIME, false)
id, e := s.ops.StoreFileHashed(full, mimeType, false, hash)
if e != nil {
_ = os.Remove(full)
return nil, e
}
att.ObjectID = id
objectID = id
if id != "" {
_ = os.Remove(full)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(full)
if objectID == "" {
if e := placeLocalBlob(full, final); e != nil {
return nil, e
}
}
att := &model.PostAttachment{
PostID: 0,
UserID: userID,
Name: name,
StoredName: hash,
BlobHash: hash,
ObjectID: objectID,
MIME: mimeType,
Size: int(written),
PricePoints: pricePoints,
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if e := acquireBlobRef(tx, hash, false, int(written), mimeType, objectID); e != nil {
return e
}
return tx.Create(att).Error
}); err != nil {
s.releasePostBlobBestEffort(hash)
return nil, err
}
return att, nil
@@ -283,14 +303,51 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
if att.UserID != userID {
return ErrAttachmentForbidden
}
path := absPath(s.dir, att.StoredName)
if err := s.db.Delete(&att).Error; err != nil {
return err
}
if att.BlobHash != "" {
return s.releasePostBlob(&att)
}
if s.ops != nil && att.ObjectID != "" {
return s.ops.RemoveObject(att.ObjectID)
}
_ = os.Remove(path)
_ = os.Remove(absPath(s.dir, att.StoredName))
return nil
}
// releasePostBlob 私有附件 blob 引用计数-1;最后一个引用删除时回收物理文件
func (s *PostFileService) releasePostBlob(att *model.PostAttachment) error {
b, last, err := releaseBlobRef(s.db, att.BlobHash, false)
if err != nil || !last {
return err
}
return s.removePrivateBlobObject(b)
}
// releasePostBlobBestEffort 附件记录落库失败时回滚刚加的引用,错误只记日志
func (s *PostFileService) releasePostBlobBestEffort(hash string) {
b, last, err := releaseBlobRef(s.db, hash, false)
if err != nil {
log.Printf("[post-file] 回滚 blob 引用失败 hash=%s: %v", hash, err)
return
}
if !last {
return
}
if err := s.removePrivateBlobObject(b); err != nil {
log.Printf("[post-file] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
}
}
// removePrivateBlobObject 引用归零后的物理回收(私有 blob 文件名即 hash,无扩展名)
func (s *PostFileService) removePrivateBlobObject(b model.Blob) error {
if b.ObjectID != "" && s.ops != nil {
return s.ops.RemoveObject(b.ObjectID)
}
if err := os.Remove(absPath(s.dir, b.Hash)); err != nil && !os.IsNotExist(err) {
log.Printf("[post-file] 删除 blob 文件失败 hash=%s: %v", b.Hash, err)
}
return nil
}

View File

@@ -202,6 +202,7 @@ type PublicSiteSettings struct {
BrandMark string `json:"brand_mark"`
BrandLogoSize string `json:"brand_logo_size"`
BrandLogoFit string `json:"brand_logo_fit"`
BrandSloganVisible bool `json:"brand_slogan_visible"`
FooterLinks []FooterLink `json:"footer_links"`
// 积分增长规则(经济规则公开透明,签到卡等可直出)

View File

@@ -113,46 +113,57 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
return nil, errors.New("头像尺寸需在 64~512px 之间")
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return nil, err
}
filename := hex.EncodeToString(nameBytes) + ".webp"
hash := sha256Hex(data)
filename := hash + ".webp"
fullPath := filepath.Join(s.dir, "avatars", filename)
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
return nil, err
url := "/uploads/avatars/" + filename
var objectID string
writeLocal := func() error {
if _, err := os.Stat(fullPath); err == nil {
return nil // 同内容头像已落盘,字节必然一致,无需重写
}
return os.WriteFile(fullPath, data, 0o644)
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindAvatar,
URL: "/uploads/avatars/" + filename,
MIME: "image/webp",
Size: len(data),
Width: cfg.Width,
Height: cfg.Height,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
if err := writeLocal(); err != nil {
return nil, err
}
id, e := s.ops.StoreFileHashed(fullPath, "image/webp", true, hash)
if e != nil {
_ = os.Remove(fullPath)
return nil, e
}
objectID = id
if id != "" {
att.URL = "/api/media/" + id
url = "/api/media/" + id
_ = os.Remove(fullPath)
}
} else if err := writeLocal(); err != nil {
return nil, err
}
// 附件记录与用户头像更新在同一事务内完成
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindAvatar,
URL: url,
BlobHash: hash,
MIME: "image/webp",
Size: len(data),
Width: cfg.Width,
Height: cfg.Height,
}
// 附件记录、blob 引用与用户头像更新在同一事务内完成
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := acquireBlobRef(tx, hash, true, len(data), "image/webp", objectID); err != nil {
return err
}
if err := tx.Create(att).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
})
if err != nil {
_ = os.Remove(fullPath) // DB 失败时清理已落盘文件
s.releaseBlobBestEffort(hash, true, "/uploads/avatars/"+filename)
return nil, err
}
return att, nil
@@ -351,29 +362,51 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*m
}
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
Source: normalizeImageSource(source),
URL: "/uploads/images/" + storeName,
MIME: storeMime,
Size: int(storeSize),
Width: w,
Height: h,
// 对最终存储字节做内容寻址(JPEG/PNG 已在上面转成 WebP,同图重复上传才能命中)
hash, err := hashFile(storePath)
if err != nil {
_ = os.Remove(storePath)
return nil, err
}
finalName := hash + strings.ToLower(filepath.Ext(storeName))
url := "/uploads/images/" + finalName
var objectID string
if s.ops != nil {
id, e := s.ops.StoreFile(storePath, att.MIME, true)
id, e := s.ops.StoreFileHashed(storePath, storeMime, true, hash)
if e != nil {
_ = os.Remove(storePath)
return nil, e
}
objectID = id
if id != "" {
att.URL = "/api/media/" + id
// 远程后端:以对象 ID 为访问地址,本地临时副本回收
url = "/api/media/" + id
_ = os.Remove(storePath)
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
return nil, e
}
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
return nil, e
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(storePath)
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
Source: normalizeImageSource(source),
URL: url,
BlobHash: hash,
MIME: storeMime,
Size: int(storeSize),
Width: w,
Height: h,
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := acquireBlobRef(tx, hash, true, int(storeSize), storeMime, objectID); err != nil {
return err
}
return tx.Create(att).Error
}); err != nil {
s.releaseBlobBestEffort(hash, true, url)
return nil, err
}
return att, nil
@@ -722,6 +755,10 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
return err
}
if att.BlobHash != "" {
return s.releaseAttachmentBlob(&att)
}
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
@@ -736,4 +773,47 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
return nil
}
// releaseAttachmentBlob 公开图片 blob 引用计数-1;最后一个引用删除时回收物理文件
func (s *UploadService) releaseAttachmentBlob(att *model.Attachment) error {
b, last, err := releaseBlobRef(s.db, att.BlobHash, true)
if err != nil || !last {
return err
}
return s.removePublicBlobObject(b, att.URL)
}
// releaseBlobBestEffort 业务记录落库失败时回滚刚加的引用,错误只记日志
func (s *UploadService) releaseBlobBestEffort(hash string, public bool, localURL string) {
b, last, err := releaseBlobRef(s.db, hash, public)
if err != nil {
log.Printf("[upload] 回滚 blob 引用失败 hash=%s: %v", hash, err)
return
}
if !last {
return
}
if err := s.removePublicBlobObject(b, localURL); err != nil {
log.Printf("[upload] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
}
}
// removePublicBlobObject 引用归零后的物理回收:远程对象失败需上抛(与历史行为一致),
// 本地文件失败只记日志(下次同内容上传会自愈覆盖)。
func (s *UploadService) removePublicBlobObject(b model.Blob, localURL string) error {
if b.ObjectID != "" && s.ops != nil {
if e := s.ops.RemoveObject(b.ObjectID); e != nil {
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
}
return nil
}
if localURL == "" {
return nil
}
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(localURL, "/uploads/")))
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
log.Printf("[upload] 删除 blob 文件失败 hash=%s path=%s: %v", b.Hash, abs, err)
}
return nil
}
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }

View File

@@ -25,7 +25,7 @@ func newUploadTestService(t *testing.T) (*UploadService, string) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Attachment{}); err != nil {
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()

View File

@@ -138,7 +138,7 @@ function DetailsInner() {
<div className="w-full sm:w-56">
<AdminSearchField
value={path}
onChange={(v) => { setPath(v); }}
onChange={(v) => { setPath(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setPath(""); resetPage(); }}
placeholder="路径模糊匹配,回车应用"
@@ -148,7 +148,7 @@ function DetailsInner() {
<div className="w-full sm:w-48">
<AdminSearchField
value={ip}
onChange={setIp}
onChange={(v) => { setIp(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setIp(""); resetPage(); }}
placeholder="按 IP 精确筛选"
@@ -158,7 +158,7 @@ function DetailsInner() {
<div className="w-full sm:w-44">
<AdminSearchField
value={bot}
onChange={setBot}
onChange={(v) => { setBot(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setBot(""); resetPage(); }}
placeholder="按爬虫名精确筛选"

View File

@@ -179,7 +179,8 @@ export default function MediaLibrary({
<Modal
open={active !== null}
onClose={() => setActive(null)}
title={active?.name ?? ""}
title={<span className="break-all">{active?.name ?? ""}</span>}
ariaLabel={active?.name ?? ""}
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
maxWidthClass="max-w-2xl"
dismissOnOverlay
@@ -218,6 +219,14 @@ export default function MediaLibrary({
["格式", active.mime],
["上传者", active.uploader || "—"],
["时间", formatTime(active.uploaded_at)],
...(active.ref_count && active.ref_count > 1
? [
[
"引用",
`${active.ref_count} 次上传 · 去重节省 ${formatBytes(active.size * (active.ref_count - 1))}`,
],
]
: []),
].map(([k, v]) => (
<div key={k} className="flex items-baseline gap-2 min-w-0">
<dt className="meta shrink-0 w-11 text-right">{k}</dt>

View File

@@ -2,7 +2,7 @@
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AdminField, AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
import { toast } from "@/lib/toast";
@@ -25,6 +25,7 @@ export default function BasicIdentityPanel({
}, []);
const [wordmark, setWordmark] = useState(initial.site_wordmark);
const [slogan, setSlogan] = useState(initial.site_slogan);
const [sloganVisible, setSloganVisible] = useState(initial.brand_slogan_visible);
const [desc, setDesc] = useState(initial.site_description);
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
const [kwInput, setKwInput] = useState("");
@@ -32,6 +33,7 @@ export default function BasicIdentityPanel({
name: initial.site_name,
wordmark: initial.site_wordmark,
slogan: initial.site_slogan,
sloganVisible: initial.brand_slogan_visible,
desc: initial.site_description,
keywords: initial.site_keywords,
});
@@ -44,6 +46,7 @@ export default function BasicIdentityPanel({
name.trim() !== saved.name ||
wordmark.trim() !== saved.wordmark ||
slogan.trim() !== saved.slogan ||
sloganVisible !== saved.sloganVisible ||
desc.trim() !== saved.desc ||
JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
@@ -85,6 +88,7 @@ export default function BasicIdentityPanel({
setName(saved.name);
setWordmark(saved.wordmark);
setSlogan(saved.slogan);
setSloganVisible(saved.sloganVisible);
setDesc(saved.desc);
setKeywords([...saved.keywords]);
setKwInput("");
@@ -98,6 +102,7 @@ export default function BasicIdentityPanel({
name: name.trim(),
wordmark: wordmark.trim(),
slogan: slogan.trim(),
sloganVisible,
desc: desc.trim(),
keywords: [...keywords],
};
@@ -115,6 +120,7 @@ export default function BasicIdentityPanel({
site_name: snap.name,
site_wordmark: snap.wordmark,
site_slogan: snap.slogan,
brand_slogan_visible: snap.sloganVisible,
site_description: snap.desc,
site_keywords: snap.keywords,
});
@@ -123,12 +129,14 @@ export default function BasicIdentityPanel({
name: res.site_name,
wordmark: res.site_wordmark,
slogan: res.site_slogan,
sloganVisible: res.brand_slogan_visible,
desc: res.site_description,
keywords: res.site_keywords,
});
setName((v) => (v.trim() === snap.name ? res.site_name : v));
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
setSloganVisible((v) => (v === snap.sloganVisible ? res.brand_slogan_visible : v));
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
setKeywords((prev) =>
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
@@ -199,18 +207,49 @@ export default function BasicIdentityPanel({
/>
</div>
<AdminField
id="site-slogan"
label="标语"
className="admin-identity-field"
maxLength={BRAND_LIMITS.slogan}
value={slogan}
onChange={(v) => {
setSlogan(v);
clearSaveError();
}}
placeholder="技术分享与讨论"
/>
<div className="admin-identity-field">
<div className="admin-field-head">
<label className="field-label" htmlFor="site-slogan">
标语
</label>
<span className="admin-field-count" aria-hidden>
{slogan.length}/{BRAND_LIMITS.slogan}
</span>
</div>
<div
className="flex items-center gap-2 mb-2"
title={slogan.trim() ? "在页眉站名下方显示副标题" : "先填写标语后可开启"}
>
<AdminSwitch
checked={sloganVisible}
disabled={!slogan.trim()}
onChange={() => {
setSloganVisible((v) => !v);
clearSaveError();
}}
label="页眉显示标语"
/>
<span
className="text-[12px]"
style={{ color: slogan.trim() ? "var(--ink-2)" : "var(--ink-3)" }}
>
页眉显示标语
</span>
</div>
<div className="admin-field-shell">
<input
id="site-slogan"
className="admin-field-input"
maxLength={BRAND_LIMITS.slogan}
value={slogan}
onChange={(e) => {
setSlogan(e.target.value);
clearSaveError();
}}
placeholder="技术分享与讨论"
/>
</div>
</div>
<p className="admin-identity-title-preview" aria-live="polite">
浏览器标题预览 · <strong>{preview}</strong>
</p>

View File

@@ -1,6 +1,6 @@
"use client";
import { Check, ImagePlus, Images, Link2, X } from "lucide-react";
import { Check, Crop, ImagePlus, Images, Link2, X } from "lucide-react";
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
AdminSegmented,
@@ -11,8 +11,6 @@ import BrandCropModal from "@/components/BrandCropModal";
import BrandLockup from "@/components/BrandLockup";
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
import {
BRAND_LOGO_FITS,
BRAND_LOGO_SIZES,
brandAccept,
brandAliasPath,
brandFileOk,
@@ -313,6 +311,17 @@ export default function BrandSeoPanel({
>
<Images size={14} /> 库
</button>
{preview && !/\.(svg|ico)(\?.*)?$/i.test(preview) ? (
<button
type="button"
className="btn btn-line btn-sm"
disabled={saving}
title="调整画幅、铺法或重新裁剪"
onClick={() => openCrop(slot, null, preview)}
>
<Crop size={14} /> 调整
</button>
) : null}
<button
type="button"
className="admin-icon-btn"
@@ -361,7 +370,7 @@ export default function BrandSeoPanel({
frame
name={initial.site_name}
wordmark={initial.site_wordmark}
slogan={initial.site_slogan}
slogan={initial.brand_slogan_visible ? initial.site_slogan : ""}
logoLight={previewOf(light)}
logoDark={previewOf(dark)}
/>
@@ -388,46 +397,9 @@ export default function BrandSeoPanel({
/>
</div>
{mark !== "text" ? (
<>
<div className="admin-brand-studio-field">
<span className="admin-brand-studio-kicker">比例</span>
<div className="admin-brand-size-row" role="group" aria-label="Logo 比例">
{BRAND_LOGO_SIZES.map((item) => {
const on = logoSize === item.id;
return (
<button
key={item.id}
type="button"
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
onClick={() => {
setLogoSize(item.id);
if (saveError) setSaveError("");
}}
>
<span className="admin-brand-size-chip-label">{item.label}</span>
<span className="admin-brand-size-chip-px">
{item.w}×{item.h}
</span>
</button>
);
})}
</div>
</div>
<div className="admin-brand-studio-field">
<span className="admin-brand-studio-kicker">铺法</span>
<AdminSegmented
role="group"
size="sm"
ariaLabel="适配方式"
value={logoFit}
onChange={(v) => {
setLogoFit(v);
if (saveError) setSaveError("");
}}
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
/>
</div>
</>
<p className="admin-brand-studio-field text-[11.5px] self-center mb-0.5" style={{ color: "var(--ink-3)" }}>
画幅与铺法在上传/调整图片的裁剪弹窗中设置
</p>
) : null}
</div>
@@ -471,6 +443,16 @@ export default function BrandSeoPanel({
slot={cropSlot}
seedFile={cropSeedFile}
seedUrl={cropSeedUrl}
logoSize={logoSize}
logoFit={logoFit}
onSizeChange={(s) => {
setLogoSize(s);
if (saveError) setSaveError("");
}}
onFitChange={(f) => {
setLogoFit(f);
if (saveError) setSaveError("");
}}
onClose={closeCrop}
onSeedConsumed={() => {
setCropSeedFile(null);

View File

@@ -5150,6 +5150,15 @@ a.j13-mention-token:hover {
flex-shrink: 0;
overflow: hidden;
}
.j13-brand-slot[data-size="auto"] {
width: auto;
max-width: 176px;
}
.j13-brand-slot[data-size="auto"] > .j13-brand-logo {
width: auto;
max-width: 176px;
object-fit: contain;
}
.j13-brand-slot[data-frame="1"] {
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 70%, transparent);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus } from "lucide-react";
@@ -44,8 +44,33 @@ export default function RegisterClient({
const [notice, setNotice] = useState(registerNotice);
const [sending, setSending] = useState(false);
const [sentMessage, setSentMessage] = useState("");
// 重发倒计时(秒);>0 期间按钮锁定不可点
const [countdown, setCountdown] = useState(0);
const countdownTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const verifyEmail = initialVerifyEmail;
// 卸载时清理倒计时,避免泄漏
useEffect(() => {
return () => {
if (countdownTimer.current) clearInterval(countdownTimer.current);
};
}, []);
const startCountdown = () => {
if (countdownTimer.current) clearInterval(countdownTimer.current);
setCountdown(60);
countdownTimer.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
if (countdownTimer.current) clearInterval(countdownTimer.current);
countdownTimer.current = null;
return 0;
}
return c - 1;
});
}, 1000);
};
// 已登录用户不必再注册,直接回首页
useEffect(() => {
if (!hasAuthCookieHint()) return;
@@ -166,8 +191,9 @@ export default function RegisterClient({
<button
type="button"
className="btn btn-line shrink-0"
disabled={sending || !email}
disabled={sending || countdown > 0 || !email}
onClick={async () => {
// 点击即锁定,防止重复发送
setSending(true);
setError("");
try {
@@ -176,6 +202,7 @@ export default function RegisterClient({
purpose: "register",
});
setSentMessage(r.message);
startCountdown();
} catch (err) {
setError((err as Error).message);
} finally {
@@ -183,7 +210,11 @@ export default function RegisterClient({
}
}}
>
{sending ? "正在排队…" : "发送验证码"}
{sending
? "正在排队…"
: countdown > 0
? `${countdown}s 后可重发`
: "发送验证码"}
</button>
</div>
{sentMessage && (

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import Cropper, { type Area } from "react-easy-crop";
import "react-easy-crop/react-easy-crop.css";
import {
@@ -10,29 +10,25 @@ import {
apiMyMedia, apiUploadBrand,
type MediaAttachment,
} from "@/lib/api";
import { getCroppedBrandWebp } from "@/lib/cropImage";
import { getCroppedBrandWebp, getFullBrandWebp } from "@/lib/cropImage";
import Modal from "@/components/Modal";
import { brandFileOk, type BrandSlot, type BrandLogoSize } from "@/lib/brand";
import BrandLockup from "@/components/BrandLockup";
import { AdminSegmented } from "@/components/admin";
import {
BRAND_LOGO_FITS,
BRAND_LOGO_SIZES,
BRAND_LOGO_SIZE_ASPECT,
brandFileOk,
brandLogoBox,
suggestLogoSize,
type BrandLogoFit,
type BrandLogoSize,
type BrandSlot,
} from "@/lib/brand";
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
// 品牌图槽位到默认裁剪比例的映射
const SLOT_ASPECT: Record<BrandSlot, number> = {
logo_light: 1, // 方标 1:1
logo_dark: 1, // 方标 1:1
favicon: 1, // Favicon 1:1
};
// 品牌图比例选项(与 BRAND_LOGO_SIZES 对应)
const ASPECT_OPTIONS: { id: BrandLogoSize; label: string; aspect: number }[] = [
{ id: "sq", label: "1:1", aspect: 1 },
{ id: "2x1", label: "2:1", aspect: 2 },
{ id: "3x1", label: "3:1", aspect: 3 },
{ id: "4x1", label: "4:1", aspect: 4 },
{ id: "16x9", label: "16:9", aspect: 16 / 9 },
];
interface BrandCropModalProps {
open: boolean;
slot: BrandSlot;
@@ -45,10 +41,19 @@ interface BrandCropModalProps {
seedUrl?: string;
/** 已消费 seedFile / seedUrl */
onSeedConsumed?: () => void;
/** 当前显示画幅(与工具栏同源,受控) */
logoSize?: BrandLogoSize;
/** 当前铺法(与工具栏同源,受控) */
logoFit?: BrandLogoFit;
/** 画幅变更回调(同步到父组件工具栏) */
onSizeChange?: (size: BrandLogoSize) => void;
/** 铺法变更回调(同步到父组件工具栏) */
onFitChange?: (fit: BrandLogoFit) => void;
}
export default function BrandCropModal({
open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed,
logoSize = "sq", logoFit = "contain", onSizeChange, onFitChange,
}: BrandCropModalProps) {
const [media, setMedia] = useState<MediaAttachment[]>([]);
const [imageSrc, setImageSrc] = useState<string | null>(null);
@@ -56,11 +61,18 @@ export default function BrandCropModal({
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [pixelCrop, setPixelCrop] = useState<Area | null>(null);
const [aspect, setAspect] = useState<number>(SLOT_ASPECT[slot] || 1);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 已识别过比例的图片(每张图进入裁剪时只自动推荐一次画幅)
const detectedSrcRef = useRef<string | null>(null);
// Favicon 固定方形,不参与画幅/铺法;auto=原图比例自由裁剪
const isFavicon = slot === "favicon";
const effectiveSize: BrandLogoSize = isFavicon ? "sq" : logoSize;
const isAuto = effectiveSize === "auto";
const cropAspect: number | undefined = isFavicon ? 1 : BRAND_LOGO_SIZE_ASPECT[effectiveSize];
// 打开时拉取媒体库
useEffect(() => {
@@ -71,6 +83,23 @@ export default function BrandCropModal({
.catch(() => setMedia([]));
}, [open]);
// 图片进入裁剪后,按原始比例自动推荐画幅(差距大→原图自适应),每张图仅一次
useEffect(() => {
if (!imageSrc || isFavicon || detectedSrcRef.current === imageSrc) return;
detectedSrcRef.current = imageSrc;
const img = new Image();
img.onload = () => {
onSizeChange?.(suggestLogoSize(img.naturalWidth, img.naturalHeight));
};
img.src = imageSrc;
}, [imageSrc, isFavicon, onSizeChange]);
// 画幅变化时重置构图位置(换画幅=重新构图;缩放保留)
useEffect(() => {
if (!open) return;
setCrop({ x: 0, y: 0 });
}, [effectiveSize, open]);
// 关闭时复位并释放本地 blob URL
useEffect(() => {
if (!open) {
@@ -82,10 +111,10 @@ export default function BrandCropModal({
setCrop({ x: 0, y: 0 });
setZoom(1);
setPixelCrop(null);
setAspect(SLOT_ASPECT[slot] || 1);
setError("");
setBusy(false);
setDragOver(false);
detectedSrcRef.current = null;
}
}, [open, slot]);
@@ -178,12 +207,15 @@ export default function BrandCropModal({
};
const confirmUpload = async () => {
if (!imageSrc || !pixelCrop || busy) return;
if (!imageSrc || busy) return;
if (!isAuto && !pixelCrop) return;
setBusy(true);
setError("");
try {
// 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB)
const blob = await getCroppedBrandWebp(imageSrc, pixelCrop, aspect);
// 原图模式:整图直传不裁剪;固定画幅:按裁剪区域导出
const blob = isAuto
? await getFullBrandWebp(imageSrc)
: await getCroppedBrandWebp(imageSrc, pixelCrop!, cropAspect!);
const file = new File([blob], "brand.webp", { type: "image/webp" });
const url = await apiUploadBrand(slot, file);
onCropped(url);
@@ -292,64 +324,166 @@ export default function BrandCropModal({
</>
) : (
<>
{/* 比例选择 */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>比例</span>
{ASPECT_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setAspect(opt.aspect)}
className={`px-2.5 py-1 rounded-md text-[12px] font-medium transition-colors ${
aspect === opt.aspect
? "bg-[var(--accent)] text-white"
: "bg-[var(--panel-2)] text-[var(--ink-2)] hover:bg-[var(--panel-3)]"
}`}
{/* 画幅:唯一的比例控件(同时决定裁剪比例与页眉图框);favicon 固定方形不展示 */}
{!isFavicon && (
<div className="mb-3">
<div className="flex items-center gap-1.5 mb-1.5">
<span className="text-[12px] font-semibold" style={{ color: "var(--ink-3)" }}>画幅</span>
<span className="text-[11px]" style={{ color: "var(--ink-3)" }}>已按图片比例推荐,可手动调整</span>
</div>
<div className="admin-brand-size-row" role="group" aria-label="显示画幅">
{BRAND_LOGO_SIZES.map((item) => {
const on = effectiveSize === item.id;
return (
<button
key={item.id}
type="button"
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
onClick={() => onSizeChange?.(item.id)}
>
<span className="admin-brand-size-chip-label">{item.label}</span>
<span className="admin-brand-size-chip-px">
{item.w ? `${item.w}×${item.h}` : "自适应"}
</span>
</button>
);
})}
</div>
</div>
)}
{/* 原图模式:整图直传,不裁剪;固定画幅:裁剪 */}
{isAuto ? (
<div
className="relative w-full h-72 rounded-xl overflow-hidden flex items-center justify-center p-4"
style={{ background: cropBg }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageSrc || ""}
alt="整图预览"
className="max-w-full max-h-full object-contain"
style={{ borderRadius: 6 }}
/>
<span
className="absolute left-2.5 top-2.5 text-[11px] px-1.5 py-0.5 rounded"
style={{
background: "rgba(0,0,0,.45)",
color: "#fff",
}}
>
{opt.label}
</button>
))}
</div>
整图使用,不裁剪
</span>
</div>
) : (
<>
<div
className="relative w-full h-72 rounded-xl overflow-hidden"
style={{ background: cropBg }}
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={cropAspect}
cropShape="rect"
showGrid={true}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
{/* 裁剪区 */}
{/* 缩放 + 重置 */}
<div className="flex items-center gap-3 mt-4">
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
aria-label="缩放"
/>
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
<button
type="button"
onClick={() => {
setZoom(1);
setCrop({ x: 0, y: 0 });
}}
className="btn btn-line btn-sm shrink-0"
title="重置缩放与位置"
>
<RotateCcw size={13} /> 重置
</button>
</div>
</>
)}
{/* 铺法:仅固定画幅需要;原图模式图框随图,无需铺法 */}
{!isFavicon && (
isAuto ? (
<p className="text-[11.5px] mt-3" style={{ color: "var(--ink-3)" }}>
原图模式:整图使用不裁剪,页眉图框按图片原始比例自动给宽,图片完整不变形。
</p>
) : (
<div className="mt-3">
<div className="flex items-center justify-between gap-3">
<span className="text-[12px] font-semibold shrink-0" style={{ color: "var(--ink-3)" }}>铺法</span>
<AdminSegmented
role="group"
size="sm"
ariaLabel="适配方式"
value={logoFit}
onChange={(v) => onFitChange?.(v)}
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
/>
</div>
<p className="text-[11.5px] mt-1.5" style={{ color: "var(--ink-3)" }}>
{logoFit === "contain"
? "适应:完整显示图片,比例不匹配时留空(推荐)"
: logoFit === "cover"
? "填充:裁切填满画幅"
: logoFit === "repeat"
? "平铺:重复铺满画幅"
: "拉伸:变形填满画幅"}
</p>
</div>
)
)}
{/* 页眉实时预览:深色 Logo 用深色衬底并局部反转文字变量 */}
<div
className="relative w-full h-72 rounded-xl overflow-hidden"
style={{ background: cropBg }}
className="mt-3 rounded-xl px-3.5 py-2.5 flex items-center min-h-[56px]"
style={{
background: cropBg,
...(slot === "logo_dark"
? ({ "--ink": "#f5f6f8", "--ink-3": "rgba(255,255,255,.55)" } as CSSProperties)
: null),
}}
aria-label="页眉预览"
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={aspect}
cropShape="rect"
showGrid={true}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
<BrandLockup
mode="image_text"
size={effectiveSize}
fit={logoFit}
name="站点名称"
wordmark=""
logoLight={imageSrc || ""}
logoDark=""
/>
</div>
{/* 缩放 */}
<div className="flex items-center gap-3 mt-4">
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
aria-label="缩放"
/>
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
</div>
{/* 输出说明 + 操作 */}
<div className="flex items-center justify-between mt-5">
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
输出 WebP,高度 ≤ 2048px
{isAuto
? "输出 WebP · 高度 40px,宽度随原图比例"
: `输出 WebP · 图框 ${brandLogoBox(effectiveSize).w}×${brandLogoBox(effectiveSize).h}px`}
</span>
<div className="flex items-center gap-2">
<button type="button" onClick={backToLibrary} disabled={busy} className="btn btn-line btn-sm">

View File

@@ -29,6 +29,8 @@ export default function BrandLockup({
const showImage = mode !== "text";
const showText = mode !== "image";
const box = brandLogoBox(size);
// auto 图槽宽度随图(可达最宽画幅量级),按 176 取密度变体保证清晰
const cssPx = size === "auto" ? 176 : Math.max(box.w, box.h);
const src = logoLight || logoDark;
const both = !!(logoLight && logoDark && logoLight !== logoDark);
const useBox = showImage && (!!src || frame);
@@ -41,16 +43,17 @@ export default function BrandLockup({
className="j13-brand-slot"
data-frame={frame ? "1" : undefined}
data-fit={src ? fit : undefined}
style={{ width: box.w, height: box.h }}
data-size={size}
style={{ width: box.w || undefined, height: box.h }}
>
{src ? (
both ? (
<>
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={Math.max(box.w, box.h)} />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={Math.max(box.w, box.h)} />
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={cssPx} />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={cssPx} />
</>
) : (
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={Math.max(box.w, box.h)} />
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={cssPx} />
)
) : (
<Bubble h={box.h} label={showText ? undefined : label} />

View File

@@ -62,6 +62,8 @@ export default function Header({
brandMark = "image_text",
brandLogoSize = "sq",
brandLogoFit = "contain",
slogan = "",
sloganVisible = true,
}: {
initialUser: User | null;
initialUnread?: number;
@@ -74,6 +76,8 @@ export default function Header({
brandMark?: BrandMark;
brandLogoSize?: BrandLogoSize;
brandLogoFit?: BrandLogoFit;
slogan?: string;
sloganVisible?: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
@@ -274,6 +278,7 @@ export default function Header({
fit={brandLogoFit}
name={siteName}
wordmark={wordmark}
slogan={sloganVisible ? slogan : ""}
logoLight={logoLight}
logoDark={logoDark}
/>

View File

@@ -2,7 +2,7 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { apiGetSettings, type PublicSettings } from "@/lib/api";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeBrandSloganVisible, normalizeFooterLinks } from "@/lib/brand";
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "@/lib/headerNav";
@@ -19,6 +19,7 @@ export type SiteBrand = {
brand_mark: PublicSettings["brand_mark"];
brand_logo_size: PublicSettings["brand_logo_size"];
brand_logo_fit: PublicSettings["brand_logo_fit"];
brand_slogan_visible: boolean;
footer_links: PublicSettings["footer_links"];
url_style: UrlStyle;
mobile_tabs: MobileTabKey[];
@@ -38,6 +39,7 @@ function fromSettings(s: PublicSettings): SiteBrand {
brand_mark: s.brand_mark,
brand_logo_size: s.brand_logo_size,
brand_logo_fit: s.brand_logo_fit,
brand_slogan_visible: s.brand_slogan_visible,
footer_links: s.footer_links,
url_style: s.url_style,
mobile_tabs: s.mobile_tabs,
@@ -56,6 +58,7 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
a.brand_mark === b.brand_mark &&
a.brand_logo_size === b.brand_logo_size &&
a.brand_logo_fit === b.brand_logo_fit &&
a.brand_slogan_visible === b.brand_slogan_visible &&
a.url_style === b.url_style &&
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) &&
JSON.stringify(a.mobile_tabs) === JSON.stringify(b.mobile_tabs) &&
@@ -108,6 +111,7 @@ export function useSiteBrand(): SiteBrand {
brand_mark: "image_text",
brand_logo_size: "sq",
brand_logo_fit: "contain",
brand_slogan_visible: true,
footer_links: [],
url_style: "default",
mobile_tabs: [...DEFAULT_MOBILE_TABS],
@@ -159,6 +163,10 @@ export default function SiteBrandProvider({
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
brand_slogan_visible:
raw.brand_slogan_visible !== undefined
? normalizeBrandSloganVisible(raw.brand_slogan_visible)
: prev.brand_slogan_visible,
footer_links: Array.isArray(raw.footer_links)
? normalizeFooterLinks(raw.footer_links)
: prev.footer_links,

View File

@@ -106,6 +106,8 @@ export default function SiteChrome({
brandMark={brand.brand_mark}
brandLogoSize={brand.brand_logo_size}
brandLogoFit={brand.brand_logo_fit}
slogan={brand.site_slogan}
sloganVisible={brand.brand_slogan_visible}
/>
<main
id="main"

View File

@@ -2,6 +2,7 @@ import {
normalizeBrandLogoFit,
normalizeBrandLogoSize,
normalizeBrandMark,
normalizeBrandSloganVisible,
normalizeBrandURL,
normalizeFooterLinks,
normalizeKeywords,
@@ -1041,6 +1042,8 @@ export interface PublicSettings {
brand_logo_size: BrandLogoSize;
/** 图槽铺法:cover 填充 / contain 适应 / repeat 平铺 / stretch 拉伸 */
brand_logo_fit: BrandLogoFit;
/** 标语是否在页眉显示;缺省 true */
brand_slogan_visible: boolean;
footer_links: FooterLink[];
allow_register: boolean;
/** 全站是否开放评论;关闭后前台不展示评论能力 */
@@ -1144,6 +1147,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
brand_mark: "image_text",
brand_logo_size: "sq",
brand_logo_fit: "contain",
brand_slogan_visible: true,
footer_links: [],
allow_register: true,
allow_comments: true,
@@ -1235,6 +1239,7 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
brand_mark: normalizeBrandMark(data?.brand_mark),
brand_logo_size: normalizeBrandLogoSize(data?.brand_logo_size),
brand_logo_fit: normalizeBrandLogoFit(data?.brand_logo_fit),
brand_slogan_visible: normalizeBrandSloganVisible(data?.brand_slogan_visible),
footer_links: normalizeFooterLinks(data?.footer_links),
allow_register: data?.allow_register !== false,
allow_comments: data?.allow_comments !== false,
@@ -3760,6 +3765,7 @@ export type UpdateSiteSettingsBody = {
brand_mark?: BrandMark;
brand_logo_size?: BrandLogoSize;
brand_logo_fit?: BrandLogoFit;
brand_slogan_visible?: boolean;
footer_links?: FooterLink[];
allow_register?: boolean;
allow_comments?: boolean;
@@ -4825,6 +4831,7 @@ export interface MediaLibraryItem {
height: number;
uploader?: string;
uploaded_at?: string;
ref_count?: number; // 引用计数(>1 表示内容相同的重复上传共享此文件)
source_url?: string; // 来源回链(帖子/评论锚点/用户主页)
source_label?: string; // 来源描述(如:帖子《…》下的评论)
}

View File

@@ -28,8 +28,8 @@ export type BrandSlot = "logo_light" | "logo_dark" | "favicon";
/** 页眉品牌形式 */
export type BrandMark = "image_text" | "image" | "text";
/** 图槽画幅。方标是默认;其余是页眉里常见的横版比例 */
export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9";
/** 图槽画幅。方标是默认;其余是页眉里常见的横版比例;auto=原图比例自适应宽度 */
export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9" | "auto";
export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h: number }[] = [
{ id: "sq", label: "方标", w: 36, h: 36 },
@@ -37,6 +37,7 @@ export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h:
{ id: "3x1", label: "3:1", w: 120, h: 40 },
{ id: "4x1", label: "4:1", w: 176, h: 44 },
{ id: "16x9", label: "16:9", w: 96, h: 54 },
{ id: "auto", label: "原图", w: 0, h: 40 },
];
export function normalizeBrandMark(raw: unknown): BrandMark {
@@ -47,10 +48,38 @@ export function normalizeBrandMark(raw: unknown): BrandMark {
export function normalizeBrandLogoSize(raw: unknown): BrandLogoSize {
const s = String(raw ?? "").trim();
if (s === "sq" || s === "2x1" || s === "3x1" || s === "4x1" || s === "16x9") return s;
if (s === "sq" || s === "2x1" || s === "3x1" || s === "4x1" || s === "16x9" || s === "auto") return s;
return "sq";
}
/** 各固定画幅的宽高比(auto 不在此表,代表自由比例) */
export const BRAND_LOGO_SIZE_ASPECT: Partial<Record<BrandLogoSize, number>> = {
sq: 1,
"2x1": 2,
"3x1": 3,
"4x1": 4,
"16x9": 16 / 9,
};
/**
* 根据图片原始比例推荐画幅:
* 与某个固定画幅差距 ≤15% 时推荐该画幅,否则推荐 auto(原图自适应)。
*/
export function suggestLogoSize(naturalW: number, naturalH: number): BrandLogoSize {
if (!naturalW || !naturalH) return "auto";
const ratio = naturalW / naturalH;
let best: BrandLogoSize = "sq";
let bestDelta = Infinity;
for (const [id, a] of Object.entries(BRAND_LOGO_SIZE_ASPECT) as [BrandLogoSize, number][]) {
const delta = Math.abs(ratio - a) / a;
if (delta < bestDelta) {
bestDelta = delta;
best = id;
}
}
return bestDelta <= 0.15 ? best : "auto";
}
export function brandLogoBox(size: BrandLogoSize): { w: number; h: number } {
const found = BRAND_LOGO_SIZES.find((item) => item.id === size);
return found ? { w: found.w, h: found.h } : { w: 36, h: 36 };
@@ -72,6 +101,13 @@ export function normalizeBrandLogoFit(raw: unknown): BrandLogoFit {
return "contain";
}
/** 标语是否在页眉显示;缺省 true(与后端缺省一致) */
export function normalizeBrandSloganVisible(raw: unknown): boolean {
if (typeof raw === "boolean") return raw;
if (typeof raw === "string") return raw.trim().toLowerCase() !== "false";
return true;
}
export const BRAND_LIMITS = {
wordmark: 16,
slogan: 32,

View File

@@ -119,3 +119,27 @@ export async function getCroppedBrandWebp(
return exportWebp(canvas, BRAND_MAX_BYTES);
}
/**
* 整图直传(「原图自适应」用):不做任何裁剪,仅按比例缩放到 BRAND_MAX_DIM 内并导出 WebP。
* 页眉图框高度固定、宽度按图片原始比例自适应。
*/
export async function getFullBrandWebp(imageSrc: string): Promise<Blob> {
const image = await loadImage(imageSrc);
const canvas = document.createElement("canvas");
const scale = Math.min(1, BRAND_MAX_DIM / Math.max(image.naturalWidth, image.naturalHeight));
const outW = Math.max(1, Math.round(image.naturalWidth * scale));
const outH = Math.max(1, Math.round(image.naturalHeight * scale));
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("当前浏览器不支持图片处理");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(image, 0, 0, outW, outH);
return exportWebp(canvas, BRAND_MAX_BYTES);
}

View File

@@ -60,6 +60,7 @@ export interface RtSettingsData {
brand_mark?: string;
brand_logo_size?: string;
brand_logo_fit?: string;
brand_slogan_visible?: boolean;
footer_links?: { label: string; url: string; new_tab: boolean; align?: string }[];
allow_register?: boolean;
allow_comments?: boolean;