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 && ( +

当前为内置默认色,保存时会清除自定义配置

+ )} +
+ + {/* 实时预览(整页已实时换肤,此处展示典型组件) */} +
+

实时预览

+
+
+ + + e.preventDefault()}> + 文字链接示例 + +
+
+ 未选中 + 选中标签 + + 导航选中态 + +
+
+
+ + + + 选中的板块卡片 +
+
+
+
+ + {/* 操作栏 */} +
+

+ {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} + + {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 的运行时换肤共用:后台实时预览时直接复用/替换此节点 +