diff --git a/backend/go.mod b/backend/go.mod
index 721924c..07d7a8a 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -40,10 +40,11 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
+ golang.org/x/image v0.46.0 // indirect
golang.org/x/net v0.45.0 // indirect
- golang.org/x/sync v0.17.0 // indirect
- golang.org/x/sys v0.37.0 // indirect
- golang.org/x/text v0.30.0 // indirect
+ golang.org/x/sync v0.23.0 // indirect
+ golang.org/x/sys v0.48.0 // indirect
+ golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/backend/go.sum b/backend/go.sum
index 1581348..8b226b2 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -91,16 +91,24 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
+golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go
index 6c72174..2b5f5de 100644
--- a/backend/handler/handlers.go
+++ b/backend/handler/handlers.go
@@ -17,4 +17,6 @@ type Handlers struct {
OverviewSvc *service.OverviewService
Checkin *service.CheckinService
Announcement *service.AnnouncementService
+ Upload *service.UploadService
+ Setting *service.SettingService
}
diff --git a/backend/handler/setting.go b/backend/handler/setting.go
new file mode 100644
index 0000000..e7acf2d
--- /dev/null
+++ b/backend/handler/setting.go
@@ -0,0 +1,44 @@
+package handler
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/freefire/jiang13-bbs/service"
+ "github.com/gin-gonic/gin"
+)
+
+// PublicSettings 公开站点设置(无需登录):前端 SSR 据此注入主题 CSS 变量
+// GET /api/settings
+func (h *Handlers) PublicSettings(c *gin.Context) {
+ accent, err := h.Setting.AccentColor()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"accent": accent})
+}
+
+type updateSettingsRequest struct {
+ // Accent 主题色 #RRGGBB;空串恢复默认
+ Accent string `json:"accent"`
+}
+
+// UpdateSettings 更新站点设置(管理员)
+// PUT /api/admin/settings
+func (h *Handlers) UpdateSettings(c *gin.Context) {
+ var req updateSettingsRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
+ return
+ }
+ if err := h.Setting.SetAccent(req.Accent); err != nil {
+ if errors.Is(err, service.ErrInvalidAccent) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true, "accent": req.Accent})
+}
diff --git a/backend/handler/upload.go b/backend/handler/upload.go
new file mode 100644
index 0000000..8ff57f1
--- /dev/null
+++ b/backend/handler/upload.go
@@ -0,0 +1,100 @@
+package handler
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "strconv"
+
+ "github.com/freefire/jiang13-bbs/middleware"
+ "github.com/freefire/jiang13-bbs/service"
+ "github.com/gin-gonic/gin"
+)
+
+// UploadAvatar 上传裁剪后的头像(multipart 字段 file,内容必须是 WebP)
+func (h *Handlers) UploadAvatar(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+
+ // 限制请求体:以裁剪后文件为依据,允许少量 multipart 边界开销
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.AvatarMaxBytes+4096)
+
+ fh, err := c.FormFile("file")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大(裁剪后不能超过 2MB)或格式不正确"})
+ return
+ }
+ if fh.Size > service.AvatarMaxBytes {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "裁剪后的图片不能超过 2MB"})
+ return
+ }
+ f, err := fh.Open()
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
+ return
+ }
+ defer f.Close()
+
+ // fh.Size 已受 MaxBytesReader 约束;读取时二次防御
+ data, err := io.ReadAll(io.LimitReader(f, service.AvatarMaxBytes+1))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
+ return
+ }
+
+ att, err := h.Upload.SaveAvatar(claims.ID, data)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
+}
+
+// UseAvatarRequest 选用历史头像请求
+type UseAvatarRequest struct {
+ URL string `json:"url"`
+}
+
+// UseAvatar 选用本人历史上传的头像
+func (h *Handlers) UseAvatar(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ var req UseAvatarRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数格式错误"})
+ return
+ }
+ if err := h.Upload.UseAvatar(claims.ID, req.URL); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"url": req.URL})
+}
+
+// MyMedia 媒体库:本人上传的全部图片(头像、帖子插图等)
+func (h *Handlers) MyMedia(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ list, err := h.Upload.ListMedia(claims.ID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"attachments": list})
+}
+
+// DeleteAttachment 彻底删除本人附件(DB + 物理文件)
+func (h *Handlers) DeleteAttachment(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的附件 ID"})
+ return
+ }
+ if err := h.Upload.DeleteAttachment(claims.ID, uint(id)); err != nil {
+ if errors.Is(err, service.ErrAttachmentInUse) {
+ c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
diff --git a/backend/handler/user.go b/backend/handler/user.go
index 21c62bc..f47471e 100644
--- a/backend/handler/user.go
+++ b/backend/handler/user.go
@@ -8,15 +8,14 @@ import (
"github.com/gin-gonic/gin"
)
-// UpdateProfileRequest 更新资料请求
+// UpdateProfileRequest 更新资料请求(头像不在此通道,见 /api/upload/avatar)
type UpdateProfileRequest struct {
Nickname string `json:"nickname"`
- Avatar string `json:"avatar"`
Email string `json:"email"`
Signature string `json:"signature"`
}
-// UpdateProfile 更新当前登录用户的资料(昵称、头像、邮箱、签名)
+// UpdateProfile 更新当前登录用户的资料(昵称、邮箱、签名)
func (h *Handlers) UpdateProfile(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req UpdateProfileRequest
@@ -25,7 +24,7 @@ func (h *Handlers) UpdateProfile(c *gin.Context) {
return
}
- user, err := h.Auth.UpdateProfile(claims.ID, req.Nickname, req.Avatar, req.Email, req.Signature)
+ user, err := h.Auth.UpdateProfile(claims.ID, req.Nickname, req.Email, req.Signature)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
diff --git a/backend/model/db.go b/backend/model/db.go
index 2430299..372b570 100644
--- a/backend/model/db.go
+++ b/backend/model/db.go
@@ -31,7 +31,7 @@ func InitDB(dsn string) error {
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
- &Announcement{}, &SiteSetting{},
+ &Announcement{}, &SiteSetting{}, &Attachment{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
diff --git a/backend/model/models.go b/backend/model/models.go
index 2c2b83e..cc355bc 100644
--- a/backend/model/models.go
+++ b/backend/model/models.go
@@ -176,3 +176,22 @@ type SiteSetting struct {
Value string `gorm:"size:255;not null;default:''" json:"value"`
UpdatedAt time.Time `json:"updated_at"`
}
+
+// 附件类型
+const (
+ AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)
+ AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
+)
+
+// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
+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"`
+ URL string `gorm:"size:512;not null" json:"url"`
+ 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"`
+ Height int `gorm:"not null;default:0" json:"height"`
+ CreatedAt time.Time `gorm:"index" json:"created_at"`
+}
diff --git a/backend/router/router.go b/backend/router/router.go
index 544a1cd..b7d69d1 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -1,6 +1,7 @@
package router
import (
+ "path/filepath"
"time"
"github.com/freefire/jiang13-bbs/config"
@@ -46,6 +47,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
overviewSvc := service.NewOverviewService(model.DB)
checkinSvc := service.NewCheckinService(model.DB)
announcementSvc := service.NewAnnouncementService(model.DB)
+ uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
+ settingSvc := service.NewSettingService(model.DB)
+ if err := uploadSvc.EnsureDir(); err != nil {
+ return nil, err
+ }
limiter := service.DefaultRateLimiter()
h := &handler.Handlers{
@@ -59,10 +65,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
OverviewSvc: overviewSvc,
Checkin: checkinSvc,
Announcement: announcementSvc,
+ Upload: uploadSvc,
+ Setting: settingSvc,
}
authMW := middleware.NewAuthMiddleware(authSvc)
+ // 上传文件静态服务(data/uploads → /uploads)
+ r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
+
// 健康检查 & SEO
r.GET("/health", h.Health)
r.GET("/robots.txt", h.RobotsTxt)
@@ -81,6 +92,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/users/:id/comments", h.UserComments)
pubAPI.GET("/announcements", h.AnnouncementsList)
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
+ pubAPI.GET("/settings", h.PublicSettings)
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
@@ -109,6 +121,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 每日签到
api.GET("/checkin", h.GetCheckin)
api.POST("/checkin", middleware.RateLimitMiddleware(limiter, service.RateComment), h.DoCheckin)
+ // 头像上传(裁剪后的 WebP)/ 媒体库 / 附件删除
+ api.POST("/upload/avatar", h.UploadAvatar)
+ api.PUT("/avatar/use", h.UseAvatar)
+ api.GET("/my/media", h.MyMedia)
+ api.DELETE("/my/attachments/:id", h.DeleteAttachment)
}
// 管理员 API(同样需要 CSRF 防护)
@@ -122,6 +139,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.POST("/announcements", h.AdminCreateAnnouncement)
adminAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
adminAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
+ // 站点外观设置(主题色)
+ adminAPI.PUT("/settings", h.UpdateSettings)
}
r.NoRoute(func(c *gin.Context) {
diff --git a/backend/service/auth.go b/backend/service/auth.go
index a2f72fe..524e0a8 100644
--- a/backend/service/auth.go
+++ b/backend/service/auth.go
@@ -493,12 +493,12 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
return s.IncrementTokenVersion(userID)
}
-// UpdateProfile 更新用户资料(昵称、头像、邮箱、签名)
+// UpdateProfile 更新昵称/邮箱/签名。头像只能通过上传接口(/api/upload/avatar)
+// 或历史头像选用接口(/api/avatar/use)修改,这里不接收头像字段,防止写入任意外链。
// - nickname 非空且不超过 64 字符
// - email 可为空;非空时需符合邮箱格式且不与他人重复
-// - avatar 可为空 URL
// - signature 不超过 255 字符
-func (s *AuthService) UpdateProfile(userID uint, nickname, avatar, email, signature string) (*model.User, error) {
+func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature string) (*model.User, error) {
nickname = strings.TrimSpace(nickname)
if nickname == "" {
return nil, errors.New("昵称不能为空")
@@ -518,16 +518,12 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, avatar, email, signat
return nil, errors.New("该邮箱已被使用")
}
}
- if len(avatar) > 512 {
- return nil, errors.New("头像链接过长")
- }
if len(signature) > 255 {
return nil, errors.New("签名不能超过 255 个字符")
}
updates := map[string]any{
"nickname": nickname,
- "avatar": avatar,
"email": email,
"signature": signature,
}
diff --git a/backend/service/board.go b/backend/service/board.go
index 4ce912a..3215f16 100644
--- a/backend/service/board.go
+++ b/backend/service/board.go
@@ -14,11 +14,38 @@ func NewBoardService(db *gorm.DB) *BoardService {
return &BoardService{db: db}
}
-// List 获取所有板块(按 sort_order 排序)
-func (s *BoardService) List() ([]model.Board, error) {
+// BoardWithCount 板块及其已发布帖子计数(Board 字段被 encoding/json 展平)
+type BoardWithCount struct {
+ model.Board
+ PostCount int64 `json:"post_count"`
+}
+
+// List 获取所有板块(按 sort_order 排序),附带各板块已发布帖子数(板块目录页展示用)
+func (s *BoardService) List() ([]BoardWithCount, error) {
var boards []model.Board
- err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error
- return boards, err
+ if err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error; err != nil {
+ return nil, err
+ }
+ type countRow struct {
+ BoardID uint
+ Cnt int64
+ }
+ var counts []countRow
+ if err := s.db.Model(&model.Post{}).
+ Select("board_id, COUNT(*) AS cnt").
+ Where("status = ?", model.ContentStatusPublished).
+ Group("board_id").Scan(&counts).Error; err != nil {
+ return nil, err
+ }
+ countMap := make(map[uint]int64, len(counts))
+ for _, c := range counts {
+ countMap[c.BoardID] = c.Cnt
+ }
+ items := make([]BoardWithCount, 0, len(boards))
+ for _, b := range boards {
+ items = append(items, BoardWithCount{Board: b, PostCount: countMap[b.ID]})
+ }
+ return items, nil
}
// Get 根据 ID 获取板块
diff --git a/backend/service/setting.go b/backend/service/setting.go
new file mode 100644
index 0000000..54ec166
--- /dev/null
+++ b/backend/service/setting.go
@@ -0,0 +1,57 @@
+package service
+
+import (
+ "errors"
+ "regexp"
+ "strings"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "gorm.io/gorm"
+)
+
+// 站点设置键
+const (
+ // SettingKeyAccent 站点主题色(浅色基准,#rrggbb);为空表示使用内置默认色
+ SettingKeyAccent = "theme_accent"
+)
+
+// ErrInvalidAccent 主题色格式非法
+var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
+
+var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
+
+// SettingService 站点级键值设置(主题色等外观配置)
+type SettingService struct {
+ db *gorm.DB
+}
+
+func NewSettingService(db *gorm.DB) *SettingService {
+ return &SettingService{db: db}
+}
+
+// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
+func (s *SettingService) AccentColor() (string, error) {
+ var st model.SiteSetting
+ err := s.db.Where("key = ?", SettingKeyAccent).First(&st).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return "", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ return strings.ToLower(st.Value), nil
+}
+
+// SetAccent 校验并保存主题色;hex 传空串表示恢复默认(删除该键)
+func (s *SettingService) SetAccent(hex string) error {
+ hex = strings.TrimSpace(strings.ToLower(hex))
+ if hex == "" {
+ return s.db.Where("key = ?", SettingKeyAccent).Delete(&model.SiteSetting{}).Error
+ }
+ if !accentHexRe.MatchString(hex) {
+ return ErrInvalidAccent
+ }
+ // UPSERT:键冲突时更新值与时间戳
+ st := model.SiteSetting{Key: SettingKeyAccent, Value: hex}
+ return s.db.Save(&st).Error
+}
diff --git a/backend/service/upload.go b/backend/service/upload.go
new file mode 100644
index 0000000..1432648
--- /dev/null
+++ b/backend/service/upload.go
@@ -0,0 +1,166 @@
+package service
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "golang.org/x/image/webp"
+ "gorm.io/gorm"
+)
+
+// 头像限制:大小限制以【裁剪后】的 WebP 文件为准(前端裁剪为正方形后上传)
+const (
+ AvatarMaxBytes = 2 << 20 // 2 MiB
+ AvatarMinDim = 64
+ AvatarMaxDim = 512
+)
+
+// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
+type UploadService struct {
+ db *gorm.DB
+ dir string // 上传根目录(如 data/uploads)
+}
+
+func NewUploadService(db *gorm.DB, uploadDir string) *UploadService {
+ return &UploadService{db: db, dir: uploadDir}
+}
+
+// EnsureDir 启动时确保上传目录存在
+func (s *UploadService) EnsureDir() error {
+ return os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755)
+}
+
+// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
+func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment, error) {
+ if len(data) == 0 {
+ return nil, errors.New("文件为空")
+ }
+ if len(data) > AvatarMaxBytes {
+ return nil, errors.New("裁剪后的图片不能超过 2MB")
+ }
+ // WebP 魔数:RIFF????WEBP
+ if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
+ return nil, errors.New("仅支持 WebP 格式图片")
+ }
+
+ // 解码尺寸(服务端不信任前端传入的宽高)
+ cfg, err := webp.DecodeConfig(bytes.NewReader(data))
+ if err != nil {
+ return nil, errors.New("无法解析 WebP 图片")
+ }
+ if cfg.Width != cfg.Height {
+ return nil, errors.New("头像必须为正方形")
+ }
+ if cfg.Width < AvatarMinDim || cfg.Width > AvatarMaxDim {
+ 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"
+ fullPath := filepath.Join(s.dir, "avatars", filename)
+ if err := os.WriteFile(fullPath, data, 0o644); err != nil {
+ return nil, err
+ }
+
+ att := &model.Attachment{
+ UserID: userID,
+ Kind: model.AttachmentKindAvatar,
+ URL: "/uploads/avatars/" + filename,
+ MIME: "image/webp",
+ Size: len(data),
+ Width: cfg.Width,
+ Height: cfg.Height,
+ }
+
+ // 附件记录与用户头像更新在同一事务内完成
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ 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 失败时清理已落盘文件
+ return nil, err
+ }
+ return att, nil
+}
+
+// UseAvatar 选用一张【本人历史上传】的头像
+func (s *UploadService) UseAvatar(userID uint, url string) error {
+ url = strings.TrimSpace(url)
+ if !strings.HasPrefix(url, "/uploads/avatars/") || len(url) > 512 {
+ return errors.New("无效的头像地址")
+ }
+ var count int64
+ s.db.Model(&model.Attachment{}).
+ Where("user_id = ? AND kind = ? AND url = ?", userID, model.AttachmentKindAvatar, url).
+ Count(&count)
+ if count == 0 {
+ return errors.New("该图片不存在或不属于你")
+ }
+ return s.db.Model(&model.User{}).Where("id = ?", userID).Update("avatar", url).Error
+}
+
+// mediaListSize 媒体库最多返回的图片数
+const mediaListSize = 60
+
+// ListMedia 媒体库:本人上传的【全部】图片(头像、帖子插图等所有 kind),最新在前
+func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
+ var list []model.Attachment
+ err := s.db.Where("user_id = ?", userID).
+ Order("id DESC").Limit(mediaListSize).Find(&list).Error
+ return list, err
+}
+
+// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
+var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
+
+// DeleteAttachment 彻底删除本人附件:DB 记录 + 物理文件。
+// - 若该图正被某篇未删除帖子的正文引用,拒绝删除(防止帖子里出现裂图)
+// - 若该图正是当前头像,同步把 user.avatar 置空(回到字母头像)
+func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
+ var att model.Attachment
+ if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
+ return errors.New("图片不存在或不属于你")
+ }
+
+ // 帖子正文引用保护(GORM 默认过滤已删除帖子)
+ var refCount int64
+ s.db.Model(&model.Post{}).
+ Where("content LIKE ?", "%"+att.URL+"%").
+ Count(&refCount)
+ if refCount > 0 {
+ return ErrAttachmentInUse
+ }
+
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ // 当前头像正指向该文件:置空,避免悬挂引用
+ if err := tx.Model(&model.User{}).
+ Where("id = ? AND avatar = ?", userID, att.URL).
+ Update("avatar", "").Error; err != nil {
+ return err
+ }
+ return tx.Delete(&model.Attachment{}, att.ID).Error
+ })
+ if err != nil {
+ return err
+ }
+
+ // DB 已清理后再删物理文件;文件删除失败只记日志(不影响用户侧结果)
+ abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(att.URL, "/uploads/")))
+ if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
+ log.Printf("[upload] 删除附件文件失败 id=%d path=%s: %v", att.ID, abs, err)
+ }
+ return nil
+}
diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx
index 6303d93..7769443 100644
--- a/frontend/app/admin/announcements/AnnouncementAdmin.tsx
+++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx
@@ -332,7 +332,7 @@ export default function AnnouncementAdmin() {
startEdit(a)}
- className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
+ className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-3)" }}
title="编辑"
aria-label="编辑公告"
diff --git a/frontend/app/admin/appearance/AppearanceAdmin.tsx b/frontend/app/admin/appearance/AppearanceAdmin.tsx
new file mode 100644
index 0000000..bb09655
--- /dev/null
+++ b/frontend/app/admin/appearance/AppearanceAdmin.tsx
@@ -0,0 +1,288 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import {
+ Palette,
+ Check,
+ Loader2,
+ Save,
+ Undo2,
+ RotateCcw,
+ ChevronRight,
+ Pipette,
+} from "lucide-react";
+import { apiUpdateSiteSettings } from "@/lib/api";
+import { toast } from "@/lib/toast";
+import {
+ ACCENT_PRESETS,
+ DEFAULT_ACCENT,
+ applyAccentTheme,
+ deriveAccentPair,
+ findPreset,
+ normalizeHex,
+} from "@/lib/theme";
+
+// 后台「外观设置」:站点主题色预设 + 自定义取色 + 实时预览。
+// 修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
+export default function AppearanceAdmin({ initialAccent }: { initialAccent: string }) {
+ const router = useRouter();
+ // 已保存值:空串=内置默认(靛蓝)
+ const [savedHex, setSavedHex] = useState(initialAccent || "");
+ const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
+ const [saving, setSaving] = useState(false);
+ const savedRef = useRef(savedHex);
+ savedRef.current = savedHex;
+
+ const normalized = useMemo(() => normalizeHex(draft), [draft]);
+ const pair = useMemo(
+ () => (normalized ? deriveAccentPair(normalized) : null),
+ [normalized]
+ );
+ const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
+ // 保存提交值:默认靛蓝落库为空串(前端不注入覆盖,保持内置令牌单一来源)
+ const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
+ const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
+ const invalid = draft.trim() !== "" && !normalized;
+
+ // 实时预览:合法颜色即应用;离开页面时还原为已保存值,避免未保存预览残留
+ useEffect(() => {
+ if (normalized) applyAccentTheme(normalized);
+ return () => {
+ applyAccentTheme(savedRef.current || null);
+ };
+ }, [normalized]);
+
+ const handleSave = async () => {
+ if (!normalized || saving) return;
+ setSaving(true);
+ try {
+ const res = await apiUpdateSiteSettings(submitHex);
+ const next = (res.accent || "").toLowerCase();
+ setSavedHex(next);
+ applyAccentTheme(next || null);
+ router.refresh();
+ toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色");
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "保存失败", "error");
+ applyAccentTheme(savedRef.current || null);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const discard = () => {
+ setDraft(savedHex || DEFAULT_ACCENT);
+ applyAccentTheme(savedHex || null);
+ };
+
+ return (
+
+ {/* 面包屑 */}
+
+
+
+
+
+
+
+
外观设置
+
配置站点主题色,全站按钮、链接、选中态会统一联动
+
+
+
+ {/* 预设色板 */}
+
+
+ 预设主题色
+
+ 浅/暗两套色板均已调校,保证文字对比度达标
+
+ {ACCENT_PRESETS.map((p) => {
+ const active = normalized === p.light;
+ const isDefault = p.light === DEFAULT_ACCENT;
+ return (
+
+ );
+ })}
+
+
+
+ {/* 自定义颜色 */}
+
+
+ 自定义颜色
+
+
+ 取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读
+
+
+ {/* 原生取色器 */}
+
+ {/* HEX 文本输入 */}
+
+
+ #
+ setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
+ className="flex-1 min-w-0 bg-transparent outline-none ml-1 uppercase"
+ style={{ color: "var(--ink)" }}
+ maxLength={7}
+ spellCheck={false}
+ aria-label="主题色 HEX 值"
+ />
+
+
+ {pair && (
+
+
+
+ 浅色 {pair.light}
+
+
+
+ 暗色 {pair.dark}
+
+
+ )}
+
+ {invalid && (
+
+ 颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
+
+ )}
+ {pair?.adjusted && (
+
+ 该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生 {pair.dark})
+
+ )}
+ {preset && preset.light === DEFAULT_ACCENT && (
+ 当前为内置默认色,保存时会清除自定义配置
+ )}
+
+
+ {/* 实时预览(整页已实时换肤,此处展示典型组件) */}
+
+ 实时预览
+
+
+
+ 未选中
+ 选中标签
+
+ 导航选中态
+
+
+
+
+
+
+ {/* 操作栏 */}
+
+
+ {dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}
+
+
+ {dirty && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/admin/appearance/page.tsx b/frontend/app/admin/appearance/page.tsx
new file mode 100644
index 0000000..0455f74
--- /dev/null
+++ b/frontend/app/admin/appearance/page.tsx
@@ -0,0 +1,43 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { cookies } from "next/headers";
+import { ShieldAlert } from "lucide-react";
+import { authCookieHeader } from "@/lib/cookies";
+import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
+import AppearanceAdmin from "./AppearanceAdmin";
+
+export const metadata: Metadata = {
+ title: "外观设置",
+ robots: { index: false, follow: false },
+};
+
+// 站点外观(主题色)管理:权限以 Go 端 RequireAdmin 为唯一判定,
+// 非管理员 SSR 直出无权限提示,写接口仍由后端 403 兜底。
+export default async function AdminAppearancePage() {
+ const cookie = authCookieHeader(await cookies());
+ const me = await getMeCached(cookie || undefined);
+
+ if (me.user?.role !== "admin") {
+ return (
+
+
+
+
+
+ 需要管理员权限
+
+
仅站点管理员可以配置站点外观。
+
+ 返回首页
+
+
+ );
+ }
+
+ // 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动
+ const { accent } = await getPublicSettingsCached();
+ return ;
+}
diff --git a/frontend/app/board/[id]/page.tsx b/frontend/app/board/[id]/page.tsx
index 03bf282..f07c913 100644
--- a/frontend/app/board/[id]/page.tsx
+++ b/frontend/app/board/[id]/page.tsx
@@ -123,7 +123,7 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
{b.name.slice(0, 1)}
diff --git a/frontend/app/boards/page.tsx b/frontend/app/boards/page.tsx
new file mode 100644
index 0000000..cb4e79a
--- /dev/null
+++ b/frontend/app/boards/page.tsx
@@ -0,0 +1,108 @@
+import Link from "next/link";
+import type { Metadata } from "next";
+import { ChevronRight, MessagesSquare, LayoutGrid } from "lucide-react";
+import { fetchBoards } from "@/lib/api";
+
+export const metadata: Metadata = {
+ title: "板块目录 · 姜十三论坛",
+ description: "浏览论坛全部板块,找到感兴趣的话题社区",
+};
+
+// 板块汇总列表页:首页左栏「板块目录」标题的落地页,
+// 展示全部板块(介绍 + 讨论量),点击进入板块独立页
+export default async function BoardsPage() {
+ const { boards } = await fetchBoards();
+ const totalPosts = boards.reduce((sum, b) => sum + (b.post_count || 0), 0);
+
+ return (
+
+ {/* 面包屑 */}
+
+
+ {/* 页头 */}
+
+
+
+
+
+
+
+
+ 板块目录
+
+
+ 共 {boards.length} 个板块 · {totalPosts} 篇讨论
+
+
+
+
+
+ {/* 板块卡片网格 */}
+ {boards.length === 0 ? (
+
+
+
+
+
还没有任何板块
+
板块由管理员创建,敬请期待
+
+ ) : (
+
+ {boards.map((b, i) => (
+
+
+ {b.name.slice(0, 1)}
+
+
+
+
+ {b.name}
+
+ {b.post_count} 篇
+
+ {b.description?.trim() && (
+
+ {b.description.trim()}
+
+ )}
+
+ 进入板块
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 0e6f0cd..6bd631f 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -217,12 +217,15 @@ h1, h2, h3, h4 {
.btn-primary {
background: var(--accent);
color: var(--accent-on);
- box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12), 0 2px 8px rgba(59, 91, 219, 0.22);
+ /* 阴影色跟随动态主题色(color-mix 引用令牌,换肤自动生效) */
+ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12),
+ 0 2px 8px color-mix(in srgb, var(--accent) 26%, transparent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hi);
transform: translateY(-1px);
- box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1), 0 8px 20px rgba(59, 91, 219, 0.3);
+ box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1),
+ 0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
@@ -463,11 +466,12 @@ h1, h2, h3, h4 {
color: var(--accent);
transform: translateY(-1px);
}
+ /* 当前页:与全站选中态一致的主题色淡化底(而非实心填充) */
.pager-btn.is-current,
.pager-btn.is-current:hover {
- background: var(--accent);
- border-color: var(--accent);
- color: var(--accent-on);
+ background: var(--nav-active-bg);
+ border-color: var(--nav-active-line);
+ color: var(--nav-active-ink);
font-weight: 700;
}
@@ -559,8 +563,8 @@ h1, h2, h3, h4 {
font-weight: 700;
}
- /* 首页左栏「板块目录」卡片:与面包屑的纯文字位置指引区分,
- 承载板块介绍与讨论量等发现型信息,点击在工作台内筛选帖子流 */
+ /* 首页左栏「全部讨论 / 板块目录」卡片:与面包屑的纯文字位置指引区分,
+ 承载讨论量等发现型信息,点击在工作台内筛选帖子流 */
.j13-bcard {
display: flex;
align-items: center;
@@ -580,10 +584,19 @@ h1, h2, h3, h4 {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
- .j13-bcard[aria-current="page"] {
- border-color: var(--accent);
+ /* 选中态:全站统一使用主题色淡化底(--nav-active-bg,随站点主题色联动),
+ 与 raillink / chip / tab / 顶部导航 pill 的选中表达保持一致 */
+ .j13-bcard[aria-current="page"],
+ .j13-bcard[aria-current="page"]:hover {
+ border-color: var(--nav-active-line);
background: var(--nav-active-bg);
}
+ .j13-bcard-name {
+ color: var(--ink);
+ }
+ .j13-bcard[aria-current="page"] .j13-bcard-name {
+ color: var(--nav-active-ink);
+ }
.j13-bcard-ico {
width: 34px;
height: 34px;
@@ -595,16 +608,6 @@ h1, h2, h3, h4 {
font-weight: 800;
flex-shrink: 0;
}
- .j13-bcard-desc {
- display: block;
- margin-top: 2px;
- font-size: 11.5px;
- line-height: 1.4;
- color: var(--ink-3);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
@media (prefers-reduced-motion: reduce) {
.j13-bcard {
transition: none;
@@ -762,8 +765,10 @@ h1, h2, h3, h4 {
border-radius: 10px;
transition: background 0.16s ease;
}
- .j13-rank:hover {
- background: var(--panel-2);
+ /* 榜单行:无选中态的导航项,悬停/聚焦统一使用主题色淡化底 */
+ .j13-rank:hover,
+ .j13-rank:focus-visible {
+ background: var(--accent-soft);
}
.j13-rank .j13-rank-no {
width: 18px;
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index cfdd511..96a1aa9 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -6,8 +6,9 @@ import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Toaster from "@/components/Toaster";
import type { User } from "@/lib/api";
-import { getMeCached } from "@/lib/serverData";
+import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
+import { accentStyleCSS } from "@/lib/theme";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
@@ -58,6 +59,11 @@ export default async function RootLayout({
// 旧版 refresh cookie 可能是 Path=/api/auth(页面请求不带),因此只要
// token 或 refresh 任一存在就向后端确认一次,避免半登录态(列表点赞态与
// Header 游客态不一致),并兼容 access 15 分钟内的自然窗口。
+ // 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
+ // 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
+ const { accent } = await getPublicSettingsCached();
+ const accentCSS = accentStyleCSS(accent || "");
+
let user: User | null = null;
let unread = 0;
const hasAuthCookie =
@@ -75,6 +81,14 @@ export default async function RootLayout({
className={`${theme === "dark" ? "dark" : ""} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`}
>
+ {accentCSS ? (
+ // id 与 lib/theme 的运行时换肤共用:后台实时预览时直接复用/替换此节点
+
+ ) : null}
跳到主要内容
diff --git a/frontend/app/notifications/NotificationsClient.tsx b/frontend/app/notifications/NotificationsClient.tsx
index 50a041c..773cfd1 100644
--- a/frontend/app/notifications/NotificationsClient.tsx
+++ b/frontend/app/notifications/NotificationsClient.tsx
@@ -69,10 +69,11 @@ export default function NotificationsClient({ initialData }: { initialData: Noti