feat: 完成头像上传、站点主题配置与UI优化

- 新增附件模型与上传服务,拆分头像上传/管理逻辑
- 重构用户资料接口,移除直接修改头像字段,改用专用上传接口
- 添加站点公开设置API与管理员外观配置页
- 全局替换hover背景为accent-soft,优化交互一致性
- 完善头像组件,支持图片加载失败回退与src更新重置
- 新增用户状态事件总线,解决同页头像更新不及时问题
- 调整依赖包顺序与CI依赖版本
- 重构板块列表接口,新增帖子计数
- 迁移账号设置到用户主页tab,合并重复代码
This commit is contained in:
2026-09-15 01:06:12 +08:00
parent f58b2af993
commit 24c6dea38c
48 changed files with 2513 additions and 432 deletions

View File

@@ -40,10 +40,11 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // 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/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -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/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 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 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 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= 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 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.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 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=

View File

@@ -17,4 +17,6 @@ type Handlers struct {
OverviewSvc *service.OverviewService OverviewSvc *service.OverviewService
Checkin *service.CheckinService Checkin *service.CheckinService
Announcement *service.AnnouncementService Announcement *service.AnnouncementService
Upload *service.UploadService
Setting *service.SettingService
} }

View File

@@ -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})
}

100
backend/handler/upload.go Normal file
View File

@@ -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})
}

View File

@@ -8,15 +8,14 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// UpdateProfileRequest 更新资料请求 // UpdateProfileRequest 更新资料请求(头像不在此通道,见 /api/upload/avatar)
type UpdateProfileRequest struct { type UpdateProfileRequest struct {
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Email string `json:"email"` Email string `json:"email"`
Signature string `json:"signature"` Signature string `json:"signature"`
} }
// UpdateProfile 更新当前登录用户的资料(昵称、头像、邮箱、签名) // UpdateProfile 更新当前登录用户的资料(昵称、邮箱、签名)
func (h *Handlers) UpdateProfile(c *gin.Context) { func (h *Handlers) UpdateProfile(c *gin.Context) {
claims := middleware.CurrentUser(c) claims := middleware.CurrentUser(c)
var req UpdateProfileRequest var req UpdateProfileRequest
@@ -25,7 +24,7 @@ func (h *Handlers) UpdateProfile(c *gin.Context) {
return 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 { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return

View File

@@ -31,7 +31,7 @@ func InitDB(dsn string) error {
if err := db.AutoMigrate( if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, &User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
&Announcement{}, &SiteSetting{}, &Announcement{}, &SiteSetting{}, &Attachment{},
); err != nil { ); err != nil {
return fmt.Errorf("自动迁移失败: %w", err) return fmt.Errorf("自动迁移失败: %w", err)
} }

View File

@@ -176,3 +176,22 @@ type SiteSetting struct {
Value string `gorm:"size:255;not null;default:''" json:"value"` Value string `gorm:"size:255;not null;default:''" json:"value"`
UpdatedAt time.Time `json:"updated_at"` 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"`
}

View File

@@ -1,6 +1,7 @@
package router package router
import ( import (
"path/filepath"
"time" "time"
"github.com/freefire/jiang13-bbs/config" "github.com/freefire/jiang13-bbs/config"
@@ -46,6 +47,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
overviewSvc := service.NewOverviewService(model.DB) overviewSvc := service.NewOverviewService(model.DB)
checkinSvc := service.NewCheckinService(model.DB) checkinSvc := service.NewCheckinService(model.DB)
announcementSvc := service.NewAnnouncementService(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() limiter := service.DefaultRateLimiter()
h := &handler.Handlers{ h := &handler.Handlers{
@@ -59,10 +65,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
OverviewSvc: overviewSvc, OverviewSvc: overviewSvc,
Checkin: checkinSvc, Checkin: checkinSvc,
Announcement: announcementSvc, Announcement: announcementSvc,
Upload: uploadSvc,
Setting: settingSvc,
} }
authMW := middleware.NewAuthMiddleware(authSvc) authMW := middleware.NewAuthMiddleware(authSvc)
// 上传文件静态服务(data/uploads → /uploads)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
// 健康检查 & SEO // 健康检查 & SEO
r.GET("/health", h.Health) r.GET("/health", h.Health)
r.GET("/robots.txt", h.RobotsTxt) 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("/users/:id/comments", h.UserComments)
pubAPI.GET("/announcements", h.AnnouncementsList) pubAPI.GET("/announcements", h.AnnouncementsList)
pubAPI.GET("/announcements/:id", h.AnnouncementDetail) pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
pubAPI.GET("/settings", h.PublicSettings)
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register) pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login) pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护) // refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
@@ -109,6 +121,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 每日签到 // 每日签到
api.GET("/checkin", h.GetCheckin) api.GET("/checkin", h.GetCheckin)
api.POST("/checkin", middleware.RateLimitMiddleware(limiter, service.RateComment), h.DoCheckin) 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 防护) // 管理员 API(同样需要 CSRF 防护)
@@ -122,6 +139,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.POST("/announcements", h.AdminCreateAnnouncement) adminAPI.POST("/announcements", h.AdminCreateAnnouncement)
adminAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement) adminAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
adminAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement) adminAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
// 站点外观设置(主题色)
adminAPI.PUT("/settings", h.UpdateSettings)
} }
r.NoRoute(func(c *gin.Context) { r.NoRoute(func(c *gin.Context) {

View File

@@ -493,12 +493,12 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
return s.IncrementTokenVersion(userID) return s.IncrementTokenVersion(userID)
} }
// UpdateProfile 更新用户资料(昵称、头像、邮箱、签名) // UpdateProfile 更新昵称/邮箱/签名。头像只能通过上传接口(/api/upload/avatar)
// 或历史头像选用接口(/api/avatar/use)修改,这里不接收头像字段,防止写入任意外链。
// - nickname 非空且不超过 64 字符 // - nickname 非空且不超过 64 字符
// - email 可为空;非空时需符合邮箱格式且不与他人重复 // - email 可为空;非空时需符合邮箱格式且不与他人重复
// - avatar 可为空 URL
// - signature 不超过 255 字符 // - 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) nickname = strings.TrimSpace(nickname)
if nickname == "" { if nickname == "" {
return nil, errors.New("昵称不能为空") return nil, errors.New("昵称不能为空")
@@ -518,16 +518,12 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, avatar, email, signat
return nil, errors.New("该邮箱已被使用") return nil, errors.New("该邮箱已被使用")
} }
} }
if len(avatar) > 512 {
return nil, errors.New("头像链接过长")
}
if len(signature) > 255 { if len(signature) > 255 {
return nil, errors.New("签名不能超过 255 个字符") return nil, errors.New("签名不能超过 255 个字符")
} }
updates := map[string]any{ updates := map[string]any{
"nickname": nickname, "nickname": nickname,
"avatar": avatar,
"email": email, "email": email,
"signature": signature, "signature": signature,
} }

View File

@@ -14,11 +14,38 @@ func NewBoardService(db *gorm.DB) *BoardService {
return &BoardService{db: db} return &BoardService{db: db}
} }
// List 获取所有板块(按 sort_order 排序) // BoardWithCount 板块及其已发布帖子计数(Board 字段被 encoding/json 展平)
func (s *BoardService) List() ([]model.Board, error) { type BoardWithCount struct {
model.Board
PostCount int64 `json:"post_count"`
}
// List 获取所有板块(按 sort_order 排序),附带各板块已发布帖子数(板块目录页展示用)
func (s *BoardService) List() ([]BoardWithCount, error) {
var boards []model.Board var boards []model.Board
err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error if err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error; err != nil {
return boards, err 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 获取板块 // Get 根据 ID 获取板块

View File

@@ -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
}

166
backend/service/upload.go Normal file
View File

@@ -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
}

View File

@@ -332,7 +332,7 @@ export default function AnnouncementAdmin() {
<Link <Link
href={`/announcement/${a.id}`} href={`/announcement/${a.id}`}
target="_blank" target="_blank"
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)" }} style={{ color: "var(--ink-3)" }}
title="查看" title="查看"
aria-label="查看公告" aria-label="查看公告"
@@ -342,7 +342,7 @@ export default function AnnouncementAdmin() {
)} )}
<button <button
onClick={() => startEdit(a)} onClick={() => 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)" }} style={{ color: "var(--ink-3)" }}
title="编辑" title="编辑"
aria-label="编辑公告" aria-label="编辑公告"

View File

@@ -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 (
<div className="max-w-3xl mx-auto">
{/* 面包屑 */}
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>外观设置</span>
</nav>
<header className="panel p-6 sm:p-7 mb-5 flex items-center gap-4">
<span
className="w-12 h-12 rounded-2xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Palette size={24} />
</span>
<div>
<h1 className="text-xl font-extrabold" style={{ color: "var(--ink)" }}>外观设置</h1>
<p className="meta mt-1 text-[13px]">配置站点主题色,全站按钮、链接、选中态会统一联动</p>
</div>
</header>
{/* 预设色板 */}
<section className="j13-module p-5 sm:p-6 mb-5">
<h2 className="text-[15px] font-extrabold mb-1" style={{ color: "var(--ink)" }}>
预设主题色
</h2>
<p className="meta text-[12.5px] mb-4">浅/暗两套色板均已调校,保证文字对比度达标</p>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{ACCENT_PRESETS.map((p) => {
const active = normalized === p.light;
const isDefault = p.light === DEFAULT_ACCENT;
return (
<button
key={p.key}
type="button"
onClick={() => setDraft(p.light)}
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
style={{
borderColor: active ? "var(--accent)" : "var(--line)",
background: active ? "var(--accent-soft)" : "var(--panel)",
cursor: "pointer",
}}
aria-pressed={active}
title={isDefault ? `${p.name}(默认)` : p.name}
>
<span className="relative flex items-center justify-center">
{/* 上半圆=浅色模式,下半圆=暗色模式 */}
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
<span style={{ background: p.light }} />
<span style={{ background: p.dark }} />
</span>
{active && (
<span
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<Check size={11} strokeWidth={3} />
</span>
)}
</span>
<span
className="text-[12px] font-bold leading-none"
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
>
{p.name}
</span>
</button>
);
})}
</div>
</section>
{/* 自定义颜色 */}
<section className="j13-module p-5 sm:p-6 mb-5">
<h2 className="text-[15px] font-extrabold mb-1 flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<Pipette size={15} /> 自定义颜色
</h2>
<p className="meta text-[12.5px] mb-4">
取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读
</p>
<div className="flex flex-wrap items-center gap-3">
{/* 原生取色器 */}
<label
className="relative w-12 h-12 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
style={{ borderColor: "var(--line-2)" }}
title="打开取色器"
>
<input
type="color"
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
value={normalized || DEFAULT_ACCENT}
onChange={(e) => setDraft(e.target.value)}
aria-label="自定义主题色取色器"
/>
</label>
{/* HEX 文本输入 */}
<div className="flex flex-col gap-1">
<div className="flex items-center rounded-[var(--r-sm)] border bg-[var(--panel)] px-3 h-12 font-mono text-[14px] w-40"
style={{ borderColor: invalid ? "var(--danger)" : "var(--line-2)" }}
>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => 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 值"
/>
</div>
</div>
{pair && (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
<span className="inline-flex items-center gap-1.5">
<span className="w-3.5 h-3.5 rounded-full border" style={{ background: pair.light, borderColor: "var(--line)" }} />
浅色 {pair.light}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="w-3.5 h-3.5 rounded-full border" style={{ background: pair.dark, borderColor: "var(--line)" }} />
暗色 {pair.dark}
</span>
</div>
)}
</div>
{invalid && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
</p>
)}
{pair?.adjusted && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生 {pair.dark})
</p>
)}
{preset && preset.light === DEFAULT_ACCENT && (
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
)}
</section>
{/* 实时预览(整页已实时换肤,此处展示典型组件) */}
<section className="j13-module p-5 sm:p-6 mb-6">
<h2 className="text-[15px] font-extrabold mb-4" style={{ color: "var(--ink)" }}>实时预览</h2>
<div className="rounded-[var(--r-sm)] border p-4 space-y-4" style={{ borderColor: "var(--line)", background: "var(--bg)" }}>
<div className="flex flex-wrap items-center gap-2.5">
<button type="button" className="btn btn-primary btn-sm">
<Save size={14} /> 主按钮
</button>
<button type="button" className="btn btn-line btn-sm">次要按钮</button>
<a className="text-[13px] font-bold" style={{ color: "var(--accent)" }} href="#preview" onClick={(e) => e.preventDefault()}>
文字链接示例
</a>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="j13-tab" aria-selected="false">未选中</span>
<span className="j13-tab" aria-selected="true">选中标签</span>
<span
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px] font-semibold"
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
>
导航选中态
</span>
</div>
<div className="max-w-xs">
<div className="j13-bcard" aria-current="page">
<span className="j13-bcard-ico" style={{ background: "var(--accent-soft)", color: "var(--accent)" }}>
<Palette size={16} />
</span>
<span className="j13-bcard-name text-[13px] font-bold flex-1">选中的板块卡片</span>
</div>
</div>
</div>
</section>
{/* 操作栏 */}
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
<p className="text-[12.5px] meta hidden sm:block">
{dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}
</p>
<div className="flex items-center gap-2.5 ml-auto">
{dirty && (
<button type="button" className="btn btn-line btn-sm" onClick={discard}>
<Undo2 size={14} /> 撤销改动
</button>
)}
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => {
setDraft(DEFAULT_ACCENT);
}}
>
<RotateCcw size={14} /> 恢复默认
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={!normalized || saving || !dirty}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存并全站生效
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="max-w-md mx-auto panel p-8 text-center mt-10">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
需要管理员权限
</h1>
<p className="meta mt-2 text-[13px]">仅站点管理员可以配置站点外观。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
);
}
// 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动
const { accent } = await getPublicSettingsCached();
return <AppearanceAdmin initialAccent={accent || ""} />;
}

View File

@@ -123,7 +123,7 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
<Link <Link
key={b.id} key={b.id}
href={`/board/${b.id}`} href={`/board/${b.id}`}
className="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-xl transition-colors hover:bg-[var(--panel-2)]" className="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-xl transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
> >
<span className={`av-${i % 8} w-8 h-8 rounded-xl flex items-center justify-center text-[13px] font-bold shrink-0`}> <span className={`av-${i % 8} w-8 h-8 rounded-xl flex items-center justify-center text-[13px] font-bold shrink-0`}>
{b.name.slice(0, 1)} {b.name.slice(0, 1)}

View File

@@ -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 (
<div>
{/* 面包屑 */}
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>板块目录</span>
</nav>
{/* 页头 */}
<div className="panel p-6 sm:p-7 mb-6 relative overflow-hidden">
<span
aria-hidden
className="absolute -top-16 -right-12 w-48 h-48 rounded-full"
style={{ background: "var(--accent-soft)" }}
/>
<div className="relative flex items-center gap-4">
<span
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<LayoutGrid size={28} />
</span>
<div>
<h1 className="text-2xl sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
板块目录
</h1>
<p className="meta mt-1.5">
共 {boards.length} 个板块 · {totalPosts} 篇讨论
</p>
</div>
</div>
</div>
{/* 板块卡片网格 */}
{boards.length === 0 ? (
<div className="panel text-center py-20 px-6">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<MessagesSquare size={26} />
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有任何板块</p>
<p className="meta mt-1.5">板块由管理员创建,敬请期待</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{boards.map((b, i) => (
<Link
key={b.id}
href={`/board/${b.id}`}
className="j13-module group flex items-start gap-3.5 transition-[border-color,background] duration-150 hover:border-[var(--nav-active-line)] hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${i % 8} j13-bcard-ico !w-11 !h-11 !text-lg`} aria-hidden="true">
{b.name.slice(0, 1)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate text-[15px] font-extrabold" style={{ color: "var(--ink)" }}>
{b.name}
</span>
<span className="meta shrink-0 text-[11.5px] tabular-nums">{b.post_count} 篇</span>
</span>
{b.description?.trim() && (
<span
className="block mt-1 text-[12.5px] leading-relaxed line-clamp-2"
style={{ color: "var(--ink-3)" }}
>
{b.description.trim()}
</span>
)}
<span
className="mt-2.5 inline-flex items-center gap-0.5 text-[12px] font-bold transition-colors group-hover:text-[var(--accent)]"
style={{ color: "var(--ink-2)" }}
>
进入板块
<ChevronRight
size={12}
className="transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</span>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -217,12 +217,15 @@ h1, h2, h3, h4 {
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);
color: var(--accent-on); 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) { .btn-primary:hover:not(:disabled) {
background: var(--accent-hi); background: var(--accent-hi);
transform: translateY(-1px); 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) { .btn-primary:active:not(:disabled) {
transform: translateY(0); transform: translateY(0);
@@ -463,11 +466,12 @@ h1, h2, h3, h4 {
color: var(--accent); color: var(--accent);
transform: translateY(-1px); transform: translateY(-1px);
} }
/* 当前页:与全站选中态一致的主题色淡化底(而非实心填充) */
.pager-btn.is-current, .pager-btn.is-current,
.pager-btn.is-current:hover { .pager-btn.is-current:hover {
background: var(--accent); background: var(--nav-active-bg);
border-color: var(--accent); border-color: var(--nav-active-line);
color: var(--accent-on); color: var(--nav-active-ink);
font-weight: 700; font-weight: 700;
} }
@@ -559,8 +563,8 @@ h1, h2, h3, h4 {
font-weight: 700; font-weight: 700;
} }
/* 首页左栏「板块目录」卡片:与面包屑的纯文字位置指引区分, /* 首页左栏「全部讨论 / 板块目录」卡片:与面包屑的纯文字位置指引区分,
承载板块介绍与讨论量等发现型信息,点击在工作台内筛选帖子流 */ 承载讨论量等发现型信息,点击在工作台内筛选帖子流 */
.j13-bcard { .j13-bcard {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -580,10 +584,19 @@ h1, h2, h3, h4 {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
} }
.j13-bcard[aria-current="page"] { /* 选中态:全站统一使用主题色淡化底(--nav-active-bg,随站点主题色联动),
border-color: var(--accent); 与 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); 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 { .j13-bcard-ico {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -595,16 +608,6 @@ h1, h2, h3, h4 {
font-weight: 800; font-weight: 800;
flex-shrink: 0; 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) { @media (prefers-reduced-motion: reduce) {
.j13-bcard { .j13-bcard {
transition: none; transition: none;
@@ -762,8 +765,10 @@ h1, h2, h3, h4 {
border-radius: 10px; border-radius: 10px;
transition: background 0.16s ease; 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 { .j13-rank .j13-rank-no {
width: 18px; width: 18px;

View File

@@ -6,8 +6,9 @@ import Header from "@/components/Header";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import Toaster from "@/components/Toaster"; import Toaster from "@/components/Toaster";
import type { User } from "@/lib/api"; 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 { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
import { accentStyleCSS } from "@/lib/theme";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正, // 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动 // 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
@@ -58,6 +59,11 @@ export default async function RootLayout({
// 旧版 refresh cookie 可能是 Path=/api/auth(页面请求不带),因此只要 // 旧版 refresh cookie 可能是 Path=/api/auth(页面请求不带),因此只要
// token 或 refresh 任一存在就向后端确认一次,避免半登录态(列表点赞态与 // token 或 refresh 任一存在就向后端确认一次,避免半登录态(列表点赞态与
// Header 游客态不一致),并兼容 access 15 分钟内的自然窗口。 // Header 游客态不一致),并兼容 access 15 分钟内的自然窗口。
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
const { accent } = await getPublicSettingsCached();
const accentCSS = accentStyleCSS(accent || "");
let user: User | null = null; let user: User | null = null;
let unread = 0; let unread = 0;
const hasAuthCookie = const hasAuthCookie =
@@ -75,6 +81,14 @@ export default async function RootLayout({
className={`${theme === "dark" ? "dark" : ""} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`} className={`${theme === "dark" ? "dark" : ""} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`}
> >
<body className="min-h-screen flex flex-col"> <body className="min-h-screen flex flex-col">
{accentCSS ? (
// id 与 lib/theme 的运行时换肤共用:后台实时预览时直接复用/替换此节点
<style
id="j13-accent-vars"
data-accent-source="ssr"
dangerouslySetInnerHTML={{ __html: accentCSS }}
/>
) : null}
<a href="#main" className="skip-link"> <a href="#main" className="skip-link">
跳到主要内容 跳到主要内容
</a> </a>

View File

@@ -69,10 +69,11 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
<button <button
key={n.id} key={n.id}
onClick={() => handleClick(n)} onClick={() => handleClick(n)}
className="w-full text-left px-4 sm:px-5 py-4 flex items-start gap-3.5 transition-colors hover:bg-[var(--panel-2)]" className={`w-full text-left px-4 sm:px-5 py-4 flex items-start gap-3.5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] ${
n.is_read ? "" : "bg-[var(--panel-2)]"
}`}
style={{ style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)", borderTop: i === 0 ? "none" : "1px solid var(--line)",
background: n.is_read ? "transparent" : "var(--panel-2)",
}} }}
> >
<span <span

View File

@@ -147,7 +147,7 @@ function AuthorRailCard({ profile }: { profile: UserProfile }) {
</p> </p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}> <Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
<Avatar name={u.nickname || u.username} size={48} /> <Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
</Link> </Link>
<div className="min-w-0"> <div className="min-w-0">
<Link <Link
@@ -198,7 +198,7 @@ function AuthorPosts({ posts }: { posts: PostListItem[] }) {
<Link <Link
href={`/post/${p.id}`} href={`/post/${p.id}`}
title={p.title} title={p.title}
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)]" className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
> >
<span <span
className="block text-[13px] font-semibold leading-snug line-clamp-2" className="block text-[13px] font-semibold leading-snug line-clamp-2"
@@ -284,7 +284,7 @@ function AuthorCardStacked({ profile }: { profile: UserProfile }) {
</p> </p>
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<Link href={`/u/${profile.user.id}`} className="shrink-0" aria-label={profile.user.nickname}> <Link href={`/u/${profile.user.id}`} className="shrink-0" aria-label={profile.user.nickname}>
<Avatar name={profile.user.nickname || profile.user.username} size={52} /> <Avatar name={profile.user.nickname || profile.user.username} src={profile.user.avatar || undefined} size={52} />
</Link> </Link>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -424,7 +424,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<Link href={`/u/${post.user.id}`} className="shrink-0" aria-label={post.user.nickname}> <Link href={`/u/${post.user.id}`} className="shrink-0" aria-label={post.user.nickname}>
<Avatar name={post.user.nickname || post.user.username} size={40} /> <Avatar name={post.user.nickname || post.user.username} src={post.user.avatar || undefined} size={40} />
</Link> </Link>
<Link <Link
href={`/u/${post.user.id}`} href={`/u/${post.user.id}`}

View File

@@ -1,106 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { apiChangePassword } from "@/lib/api";
/**
* 修改密码表单:提交成功后后端已递增 TokenVersion 并清除全部登录 cookie,
* 刷新 layout 使 Header 回到游客态,随后跳转登录页重新登录。
*/
export default function PasswordForm() {
const router = useRouter();
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (newPassword.length < 6) {
setError("新密码至少 6 位");
return;
}
if (newPassword !== confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
setSubmitting(true);
try {
const res = await apiChangePassword(oldPassword, newPassword);
if (res.message) {
setSuccess(true);
// 后端已使全部 token 失效并清 cookie,刷新 layout 让 Header 回到游客态
router.refresh();
setTimeout(() => router.push("/login"), 1500);
} else {
setError(res.error || "修改失败");
}
} catch {
setError("网络错误,请重试");
} finally {
setSubmitting(false);
}
};
return (
<>
<p className="font-bold text-[15px] mb-3.5" style={{ color: "var(--ink)" }}>修改密码</p>
<form onSubmit={handleSubmit} className="panel p-5 sm:p-6 space-y-4">
{success && <div className="alert-ok" role="status">密码修改成功,正在跳转登录页...</div>}
{error && <div className="alert-error" role="alert">{error}</div>}
<div>
<label className="field-label" htmlFor="pwd-old">当前密码</label>
<input
id="pwd-old"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
autoComplete="current-password"
className="field"
/>
</div>
<div>
<label className="field-label" htmlFor="pwd-new">新密码(至少 6 位)</label>
<input
id="pwd-new"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="field"
/>
</div>
<div>
<label className="field-label" htmlFor="pwd-confirm">确认新密码</label>
<input
id="pwd-confirm"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="field"
/>
</div>
<button
type="submit"
disabled={submitting || success}
className="btn btn-primary w-full justify-center mt-2"
>
{submitting ? "提交中..." : "修改密码"}
</button>
</form>
</>
);
}

View File

@@ -1,50 +1,15 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { ChevronRight } from "lucide-react"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@/lib/cookies"; import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData"; import { getMeCached } from "@/lib/serverData";
import Avatar from "@/components/Avatar";
import ProfileForm from "./ProfileForm";
import PasswordForm from "./PasswordForm";
// SSR 直出账号信息:未登录直接跳登录页,登录后 F5 不再有"正在加载…"闪动 // /settings 已融合到用户主页 /u/[id]?tab=settings
export default async function SettingsPage() { // 未登录跳登录页,登录后跳自己的主页 settings tab
export default async function SettingsRedirect() {
const cookie = authCookieHeader(await cookies()); const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined); const me = await getMeCached(cookie || undefined);
const user = me.user; if (!me.user) {
if (!user) {
redirect(`/login?redirect=${encodeURIComponent(`/settings`)}`); redirect(`/login?redirect=${encodeURIComponent(`/settings`)}`);
} }
redirect(`/u/${me.user.id}?tab=settings`);
return (
<div className="max-w-md mx-auto">
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>账号设置</span>
</nav>
<div className="mb-7">
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
账号设置
</h1>
</div>
<div className="panel p-5 mb-7 flex items-center gap-4">
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={46} />
<div className="min-w-0">
<div className="font-bold truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</div>
<div className="meta">@{user.username} · {user.role === "admin" ? "管理员" : "普通用户"}</div>
</div>
</div>
<div className="space-y-7">
<ProfileForm user={user} />
<PasswordForm />
</div>
</div>
);
} }

View File

@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { apiChangePassword } from "@/lib/api";
/**
* 修改密码表单:提交成功后后端已递增 TokenVersion 并清除全部登录 cookie,
* 刷新 layout 使 Header 回到游客态,随后跳转登录页重新登录。
*/
export default function PasswordForm() {
const router = useRouter();
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (newPassword.length < 6) {
setError("新密码至少 6 位");
return;
}
if (newPassword !== confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
setSubmitting(true);
try {
const res = await apiChangePassword(oldPassword, newPassword);
if (res.message) {
setSuccess(true);
router.refresh();
setTimeout(() => router.push("/login"), 1500);
} else {
setError(res.error || "修改失败");
}
} catch {
setError("网络错误,请重试");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="panel p-5 sm:p-6 space-y-4">
{success && (
<div className="alert-ok flex items-center gap-2" role="status">
<Check size={15} /> 密码修改成功,正在跳转登录页...
</div>
)}
{error && <div className="alert-error" role="alert">{error}</div>}
<div>
<label className="field-label" htmlFor="pwd-old">当前密码</label>
<input
id="pwd-old"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
autoComplete="current-password"
className="field"
/>
</div>
<div>
<label className="field-label" htmlFor="pwd-new">新密码(至少 6 位)</label>
<input
id="pwd-new"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="field"
/>
</div>
<div>
<label className="field-label" htmlFor="pwd-confirm">确认新密码</label>
<input
id="pwd-confirm"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="field"
/>
</div>
<div className="flex justify-end pt-1 border-t" style={{ borderColor: "var(--line)" }}>
<button
type="submit"
disabled={submitting || success}
className="btn btn-primary w-full sm:w-auto"
>
{submitting ? "提交中..." : "修改密码"}
</button>
</div>
</form>
);
}

View File

@@ -2,20 +2,22 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { apiUpdateProfile, type User } from "@/lib/api"; import { apiUpdateProfile, type User } from "@/lib/api";
import AvatarPicker from "@/components/AvatarPicker";
interface ProfileFormProps { interface ProfileFormProps {
user: User; user: User;
} }
/** /**
* 个人资料编辑表单:昵称、头像 URL、邮箱、签名。 * 个人资料编辑表单:头像(上传/裁剪/历史选择)、昵称、邮箱、签名。
* 提交成功后刷新 layout 使 Header 同步最新昵称/头像,并在页面内显示成功提示。 * 头像由独立上传链路处理,不随本表单提交;保存成功后 router.refresh()
* 让 Header 等 SSR 区域同步最新昵称。
*/ */
export default function ProfileForm({ user }: ProfileFormProps) { export default function ProfileForm({ user }: ProfileFormProps) {
const router = useRouter(); const router = useRouter();
const [nickname, setNickname] = useState(user.nickname || ""); const [nickname, setNickname] = useState(user.nickname || "");
const [avatar, setAvatar] = useState(user.avatar || "");
const [email, setEmail] = useState(user.email || ""); const [email, setEmail] = useState(user.email || "");
const [signature, setSignature] = useState(user.signature || ""); const [signature, setSignature] = useState(user.signature || "");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -45,13 +47,11 @@ export default function ProfileForm({ user }: ProfileFormProps) {
try { try {
const res = await apiUpdateProfile({ const res = await apiUpdateProfile({
nickname: trimmed, nickname: trimmed,
avatar: avatar.trim(),
email: email.trim(), email: email.trim(),
signature, signature,
}); });
if (res.user) { if (res.user) {
setSuccess("资料已保存"); setSuccess("资料已保存");
// 刷新 layout 让 Header 同步最新昵称/头像
router.refresh(); router.refresh();
} else { } else {
setError(res.error || "保存失败"); setError(res.error || "保存失败");
@@ -64,15 +64,34 @@ export default function ProfileForm({ user }: ProfileFormProps) {
}; };
return ( return (
<> <form onSubmit={handleSubmit} className="panel p-5 sm:p-7 space-y-5">
<p className="font-bold text-[15px] mb-3.5" style={{ color: "var(--ink)" }}> {success && (
个人资料 <div className="alert-ok flex items-center gap-2" role="status">
</p> <Check size={15} /> {success}
</div>
)}
{error && <div className="alert-error" role="alert">{error}</div>}
<form onSubmit={handleSubmit} className="panel p-5 sm:p-6 space-y-5"> {/* 头像:点击上传/裁剪,或从历史图片中选择 */}
{success && <div className="alert-ok" role="status">{success}</div>} <div
{error && <div className="alert-error" role="alert">{error}</div>} className="flex items-center gap-4 p-4 rounded-xl"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<AvatarPicker
name={nickname || user.username}
src={user.avatar || ""}
size={56}
/>
<div className="flex-1 min-w-0">
<p className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>头像</p>
<p className="text-[12px] mt-1" style={{ color: "var(--ink-3)" }}>
点击头像上传新图片,支持拖动裁剪;统一转为 WebP,裁剪后不超过 2MB
</p>
</div>
</div>
{/* 昵称 + 邮箱:桌面双列高密度排布 */}
<div className="grid sm:grid-cols-2 gap-5">
<div> <div>
<label className="field-label" htmlFor="pf-nickname">昵称</label> <label className="field-label" htmlFor="pf-nickname">昵称</label>
<input <input
@@ -86,21 +105,6 @@ export default function ProfileForm({ user }: ProfileFormProps) {
placeholder="请输入昵称" placeholder="请输入昵称"
/> />
</div> </div>
<div>
<label className="field-label" htmlFor="pf-avatar">头像链接</label>
<input
id="pf-avatar"
type="url"
value={avatar}
onChange={(e) => setAvatar(e.target.value)}
maxLength={512}
className="field"
placeholder="https://example.com/avatar.png"
/>
<p className="meta mt-2">填写图片 URL,留空则使用默认字母头像</p>
</div>
<div> <div>
<label className="field-label" htmlFor="pf-email">邮箱</label> <label className="field-label" htmlFor="pf-email">邮箱</label>
<input <input
@@ -113,33 +117,37 @@ export default function ProfileForm({ user }: ProfileFormProps) {
placeholder="your@email.com" placeholder="your@email.com"
/> />
</div> </div>
</div>
<div> {/* 个性签名 */}
<label className="field-label" htmlFor="pf-signature"> <div>
个性签名 <label className="field-label" htmlFor="pf-signature">
<span className="font-normal ml-1.5" style={{ color: "var(--ink-3)" }}> 个性签名
({signature.length}/255) <span className="font-normal ml-1.5" style={{ color: "var(--ink-3)" }}>
</span> ({signature.length}/255)
</label> </span>
<textarea </label>
id="pf-signature" <textarea
value={signature} id="pf-signature"
onChange={(e) => setSignature(e.target.value)} value={signature}
maxLength={255} onChange={(e) => setSignature(e.target.value)}
rows={3} maxLength={255}
className="field" rows={3}
placeholder="介绍一下自己吧" className="field"
/> placeholder="介绍一下自己吧"
</div> />
</div>
{/* 操作区:桌面右对齐 */}
<div className="flex justify-end pt-1 border-t" style={{ borderColor: "var(--line)" }}>
<button <button
type="submit" type="submit"
disabled={submitting} disabled={submitting}
className="btn btn-primary w-full justify-center mt-1" className="btn btn-primary w-full sm:w-auto"
> >
{submitting ? "保存中..." : "保存资料"} {submitting ? "保存中..." : "保存资料"}
</button> </button>
</form> </div>
</> </form>
); );
} }

View File

@@ -7,6 +7,7 @@ import type { UserProfile, UserCommentsResponse } from "@/lib/api";
/** /**
* 用户主页内容区(纯服务端渲染): * 用户主页内容区(纯服务端渲染):
* tab 与分页全部走 URL(?tab=comments&page=N),刷新/后退/分享链接均保持一致。 * tab 与分页全部走 URL(?tab=comments&page=N),刷新/后退/分享链接均保持一致。
* Tab 切换栏已移至 page.tsx 统一渲染(含 settings / security),此组件仅负责 posts / comments 列表。
*/ */
export default function UserProfileContent({ export default function UserProfileContent({
userId, userId,
@@ -23,47 +24,8 @@ export default function UserProfileContent({
commentError: boolean; commentError: boolean;
page: number; page: number;
}) { }) {
const tabLink = (t: "posts" | "comments", label: string, count: number, icon: React.ReactNode) => {
const active = tab === t;
const href = t === "posts" ? `/u/${userId}` : `/u/${userId}?tab=comments`;
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-full text-sm font-semibold transition-all flex-1 sm:flex-none ${
active ? "" : "hover:text-[var(--ink)]"
}`}
style={{
background: active ? "var(--panel)" : "transparent",
color: active ? "var(--accent)" : "var(--ink-3)",
boxShadow: active ? "var(--shadow-sm)" : "none",
}}
>
{icon}
{label}
<span
className="text-[11px] font-bold px-1.5 py-px rounded-full tabular-nums"
style={{
background: active ? "var(--accent-soft)" : "var(--tint)",
color: active ? "var(--accent)" : "var(--ink-3)",
}}
>
{count}
</span>
</Link>
);
};
return ( return (
<div> <div>
<div
className="inline-flex w-full sm:w-auto items-center gap-1 mb-6 p-1 rounded-full"
style={{ background: "var(--panel-2)" }}
>
{tabLink("posts", "发帖", profile.stats.post_count, <FileText size={15} />)}
{tabLink("comments", "评论", profile.stats.comment_count, <MessageCircle size={15} />)}
</div>
{tab === "posts" ? ( {tab === "posts" ? (
<div> <div>
{profile.posts.length > 0 ? ( {profile.posts.length > 0 ? (
@@ -72,7 +34,7 @@ export default function UserProfileContent({
<Link <Link
key={post.id} key={post.id}
href={`/post/${post.id}`} href={`/post/${post.id}`}
className="group block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]" className="group block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }} style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
> >
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="flex items-center gap-2 mb-2 flex-wrap">
@@ -128,7 +90,7 @@ export default function UserProfileContent({
<Link <Link
key={c.id} key={c.id}
href={`/post/${c.post_id}`} href={`/post/${c.post_id}`}
className="block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]" className="block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }} style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
> >
<div className="meta text-xs mb-2"> <div className="meta text-xs mb-2">

View File

@@ -1,8 +1,16 @@
import Link from "next/link"; import Link from "next/link";
import { cookies } from "next/headers";
import { Metadata } from "next"; import { Metadata } from "next";
import { ChevronRight, FileText, MessageCircle, Flame, AlertCircle } from "lucide-react"; import {
ChevronRight, FileText, MessageCircle, Flame, Calendar, Hash, Shield,
Settings, KeyRound, Info, PenLine,
} from "lucide-react";
import { fetchUserProfile, fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api"; import { fetchUserProfile, fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import UserProfileContent from "./UserProfileContent"; import UserProfileContent from "./UserProfileContent";
import ProfileForm from "./ProfileForm";
import PasswordForm from "./PasswordForm";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
interface UserPageProps { interface UserPageProps {
@@ -10,6 +18,8 @@ interface UserPageProps {
searchParams: Promise<{ page?: string; tab?: string }>; searchParams: Promise<{ page?: string; tab?: string }>;
} }
export type UserTab = "posts" | "comments" | "settings" | "security";
export async function generateMetadata({ params }: UserPageProps): Promise<Metadata> { export async function generateMetadata({ params }: UserPageProps): Promise<Metadata> {
const { id } = await params; const { id } = await params;
try { try {
@@ -23,11 +33,19 @@ export async function generateMetadata({ params }: UserPageProps): Promise<Metad
} }
} }
// 装饰几何色:全部由主题变量派生,浅色为淡靛、暗色自动提亮
const ACCENT_RING_1 = "color-mix(in srgb, var(--accent) 14%, transparent)";
const ACCENT_RING_2 = "color-mix(in srgb, var(--accent) 9%, transparent)";
export default async function UserPage({ params, searchParams }: UserPageProps) { export default async function UserPage({ params, searchParams }: UserPageProps) {
const { id } = await params; const { id } = await params;
const sp = await searchParams; const sp = await searchParams;
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1); const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const tab: "posts" | "comments" = sp.tab === "comments" ? "comments" : "posts"; const rawTab = sp.tab;
const tab: UserTab =
rawTab === "comments" ? "comments" :
rawTab === "settings" ? "settings" :
rawTab === "security" ? "security" : "posts";
let data: UserProfile; let data: UserProfile;
try { try {
@@ -46,10 +64,18 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
); );
} }
// 评论 tab:SSR 直出对应分页,URL(?tab=comments&page=N)与内容严格一致 // 判断是否本人(决定能否看到 settings / security tab)
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
const isOwner = me.user?.id === data.user.id;
const currentUser = me.user;
const effectiveTab: UserTab =
(tab === "settings" || tab === "security") && !isOwner ? "posts" : tab;
// 评论 tab:SSR 直出对应分页
let commentData: UserCommentsResponse | null = null; let commentData: UserCommentsResponse | null = null;
let commentError = false; let commentError = false;
if (tab === "comments") { if (effectiveTab === "comments") {
try { try {
commentData = await fetchUserComments(id, page); commentData = await fetchUserComments(id, page);
} catch { } catch {
@@ -58,91 +84,324 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
} }
const u = data.user; const u = data.user;
const stats = [ const isAdmin = u.role === "admin";
{ label: "发帖", value: data.stats.post_count, icon: <FileText size={15} /> }, const joinDate = new Date(u.created_at).toLocaleDateString("zh-CN");
{ label: "评论", value: data.stats.comment_count, icon: <MessageCircle size={15} /> },
];
const showPoints = data.stats.points > 0; const showPoints = data.stats.points > 0;
// 导航项(桌面左栏 / 移动 chips 共用定义)
const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [
{ key: "posts", label: "发帖", count: data.stats.post_count, icon: <FileText size={15} /> },
{ key: "comments", label: "评论", count: data.stats.comment_count, icon: <MessageCircle size={15} /> },
{ key: "settings", label: "资料设置", icon: <Settings size={15} />, ownerOnly: true },
{ key: "security", label: "安全设置", icon: <KeyRound size={15} />, ownerOnly: true },
];
const visibleNav = navItems.filter((t) => !t.ownerOnly || isOwner);
const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`);
// 统计条数据
const heroStats = [
{ label: "发帖", value: data.stats.post_count },
{ label: "评论", value: data.stats.comment_count },
{ label: "积分", value: data.stats.points, foot: data.stats.streak > 0 ? `连签 ${data.stats.streak} 天` : undefined },
];
return ( return (
<div className="max-w-4xl mx-auto"> <div>
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]"> <nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link> <Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} /> <ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>{u.nickname || u.username}</span> <span style={{ color: "var(--accent)" }}>{u.nickname || u.username}</span>
</nav> </nav>
{/* 用户信息卡片 */} {/* ═══ Hero:石墨面板 + 淡靛几何装饰 + 身份信息 + 统计条 ═══ */}
<section className="panel relative overflow-hidden p-6 sm:p-8 mb-6"> <section className="panel relative mb-6 overflow-hidden">
<span {/* 装饰层:裁在面板圆角内 */}
aria-hidden <div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
className="absolute -top-20 -right-16 w-60 h-60 rounded-full" <span
style={{ background: "var(--accent-soft)" }} className="absolute -top-28 -right-24 w-[440px] h-[440px] rounded-full"
/> style={{ background: "radial-gradient(circle, var(--accent-soft) 0%, transparent 68%)" }}
<div className="relative flex items-start gap-5 sm:gap-6">
<Avatar
name={u.nickname || u.username}
src={u.avatar || undefined}
size={88}
/> />
<div className="flex-1 min-w-0"> <span
<div className="flex items-center gap-2.5 flex-wrap"> className="absolute -top-[150px] -right-[52px] w-[390px] h-[390px] rounded-full border"
<h1 className="text-[24px] sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}> style={{ borderColor: ACCENT_RING_1 }}
{u.nickname || u.username} />
</h1> <span
{u.role === "admin" && ( className="absolute -top-[96px] -right-[8px] w-[290px] h-[290px] rounded-full border"
<span style={{ borderColor: ACCENT_RING_2 }}
className="text-[11.5px] font-bold px-2.5 py-1 rounded-full" />
style={{ background: "var(--gold-soft)", color: "var(--gold)" }} </div>
{/* 本人操作按钮 */}
{isOwner && (
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 z-20 flex items-center gap-2">
<Link href={`/u/${id}?tab=settings`} className="btn btn-line btn-sm">
<PenLine size={13.5} />
<span className="hidden sm:inline">编辑资料</span>
</Link>
<Link
href={`/u/${id}?tab=security`}
aria-label="安全设置"
className="inline-flex w-8 h-8 items-center justify-center rounded-full border transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
style={{ background: "var(--panel)", borderColor: "var(--line-2)", color: "var(--ink-2)" }}
>
<Shield size={14} />
</Link>
</div>
)}
{/* 身份信息:纯文档流,头像 + 右侧文字 baseline 对齐 */}
<div className="relative z-10 px-5 sm:px-7 pt-5 sm:pt-6">
<div className="flex items-center gap-4">
<span
className="inline-flex items-center justify-center shrink-0 rounded-full p-0"
style={{
width: 80,
height: 80,
lineHeight: 0,
boxShadow:
"0 0 0 4px var(--panel), 0 0 0 6px color-mix(in srgb, var(--accent) 38%, transparent)",
}}
>
<Avatar
name={u.nickname || u.username}
src={u.avatar || undefined}
size={80}
/>
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1
className="text-[19px] sm:text-[22px] font-extrabold tracking-tight leading-tight truncate"
style={{ color: "var(--ink)" }}
> >
管理员 {u.nickname || u.username}
</span> </h1>
)} {isAdmin && (
</div> <span
<p className="meta mt-1.5 text-[13px]"> className="text-[11px] font-bold px-2 py-0.5 rounded-full"
@{u.username} · {new Date(u.created_at).toLocaleDateString("zh-CN")} 加入 style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
</p> >
{u.signature && ( 管理员
<p className="mt-3 text-[14px] leading-relaxed" style={{ color: "var(--ink-2)" }}> </span>
{u.signature} )}
</div>
<p className="meta mt-1 flex items-center gap-1.5 text-[12.5px] flex-wrap">
<span>@{u.username}</span>
<span aria-hidden>·</span>
<Calendar size={12} />
<span>{joinDate} 加入</span>
</p> </p>
)}
<div className="flex gap-2.5 mt-4 flex-wrap">
{stats.map((s) => (
<span
key={s.label}
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-full text-[13px] font-semibold"
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
>
<span style={{ color: "var(--accent)" }}>{s.icon}</span>
<span className="tabular-nums">{s.value}</span> {s.label}
</span>
))}
{showPoints && (
<span
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-full text-[13px] font-semibold"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
title={`连续签到 ${data.stats.streak} 天`}
>
<Flame size={15} />
<span className="tabular-nums">{data.stats.points}</span> 积分
{data.stats.streak > 0 && <span className="font-normal opacity-80">· 连签 {data.stats.streak} 天</span>}
</span>
)}
</div> </div>
</div> </div>
{u.signature && (
<p
className="mt-3.5 text-[13.5px] leading-relaxed"
style={{ color: "var(--ink-2)" }}
>
{u.signature}
</p>
)}
</div>
{/* 统计条 */}
<div className="relative z-10 grid grid-cols-3 mt-4 border-t" style={{ borderColor: "var(--line)" }}>
{heroStats.map((s, i) => (
<div
key={s.label}
className="px-5 sm:px-7 py-3.5"
style={i > 0 ? { borderLeft: "1px solid var(--line)" } : undefined}
>
<p
className="text-[19px] sm:text-[21px] font-extrabold leading-none tabular-nums"
style={{ color: "var(--ink)" }}
>
{s.value}
</p>
<p className="mt-1.5 text-[11.5px] flex items-center gap-1" style={{ color: "var(--ink-3)" }}>
{s.label}
{s.foot && (
<span className="hidden sm:inline-flex items-center gap-0.5 font-semibold" style={{ color: "var(--gold)" }}>
<Flame size={10.5} /> {s.foot}
</span>
)}
</p>
</div>
))}
</div> </div>
</section> </section>
{/* 发帖 / 评论 切换(URL 驱动,SSR 直出) */} {/* 移动端 Tab chips(lg 以下) */}
<UserProfileContent <div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 mb-5">
userId={id} <div className="j13-chips">
profile={data} {visibleNav.map((t) => (
tab={tab} <Link
commentData={commentData} key={t.key}
commentError={commentError} href={tabHref(t.key)}
page={page} className="j13-chip"
/> aria-current={effectiveTab === t.key ? "page" : undefined}
>
{t.icon}
{t.label}
{t.count !== undefined && (
<span className="tabular-nums opacity-70">{t.count}</span>
)}
</Link>
))}
</div>
</div>
{/* ═══ 三栏:左导航 / 中内容 / 右信息 ═══ */}
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
{/* 左栏:Tab 导航 */}
<aside className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-24 flex-col gap-4">
<section className="j13-module !p-2">
<p
className="px-2.5 pt-1.5 pb-2 text-[11.5px] font-bold tracking-[0.08em] uppercase"
style={{ color: "var(--ink-3)" }}
>
个人主页
</p>
<div className="space-y-0.5">
{visibleNav.map((t) => (
<Link
key={t.key}
href={tabHref(t.key)}
className="j13-raillink"
aria-current={effectiveTab === t.key ? "page" : undefined}
>
{t.icon}
<span className="flex-1 truncate">{t.label}</span>
{t.count !== undefined && (
<span className="text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{t.count}
</span>
)}
</Link>
))}
</div>
</section>
</aside>
{/* 中栏:Tab 内容 */}
<div className="min-w-0">
{effectiveTab === "posts" || effectiveTab === "comments" ? (
<UserProfileContent
userId={id}
profile={data}
tab={effectiveTab}
commentData={commentData}
commentError={commentError}
page={page}
/>
) : effectiveTab === "settings" && isOwner && currentUser ? (
<div>
<div className="mb-4">
<h2 className="text-[18px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
编辑个人资料
</h2>
<p className="meta mt-1 text-[13px]">昵称、头像和签名会公开展示,邮箱仅你自己可见</p>
</div>
<ProfileForm user={currentUser} />
</div>
) : effectiveTab === "security" && isOwner ? (
<div>
<div className="mb-4">
<h2 className="text-[18px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
修改密码
</h2>
<p className="meta mt-1 text-[13px]">修改成功后将在所有设备登出,需要使用新密码重新登录</p>
</div>
<PasswordForm />
</div>
) : null}
</div>
{/* 右栏:账号信息 + 积分 + 安全提示 */}
<aside className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden">
<section className="j13-module">
<p className="j13-module-title mb-3">
<Hash size={15} style={{ color: "var(--accent)" }} /> 账号信息
</p>
<div className="space-y-3">
<div>
<p className="j13-pulse-lab">用户名</p>
<p className="text-[13px] font-semibold mt-0.5" style={{ color: "var(--ink)" }}>
{u.username}
</p>
</div>
<div className="pt-3 border-t" style={{ borderColor: "var(--line)" }}>
<p className="j13-pulse-lab">
<Calendar size={11.5} /> 注册时间
</p>
<p className="text-[13px] font-semibold mt-0.5 tabular-nums" style={{ color: "var(--ink)" }}>
{joinDate}
</p>
</div>
<div className="pt-3 border-t" style={{ borderColor: "var(--line)" }}>
<p className="j13-pulse-lab">
<Shield size={11.5} /> 角色
</p>
<p className="text-[13px] font-semibold mt-0.5" style={{ color: "var(--ink)" }}>
{isAdmin ? "管理员" : "普通用户"}
</p>
</div>
<div className="pt-3 border-t" style={{ borderColor: "var(--line)" }}>
<p className="j13-pulse-lab">用户 ID</p>
<p className="text-[13px] font-semibold mt-0.5 tabular-nums" style={{ color: "var(--ink)" }}>
#{u.id}
</p>
</div>
</div>
</section>
{showPoints && (
<section className="j13-module">
<div className="flex items-center justify-between">
<p className="j13-module-title">
<Flame size={15} style={{ color: "var(--clay)" }} /> 签到
</p>
{data.stats.streak > 0 && (
<span
className="inline-flex items-center gap-1 text-[11.5px] font-bold tabular-nums"
style={{ color: "var(--gold)" }}
>
<Flame size={11.5} /> {data.stats.streak} 天连签
</span>
)}
</div>
<p
className="mt-2 text-[26px] font-extrabold tabular-nums leading-none"
style={{ color: "var(--accent)" }}
>
{data.stats.points}
</p>
<p className="meta text-[11px] mt-1.5">累计积分</p>
</section>
)}
{isOwner && (effectiveTab === "settings" || effectiveTab === "security") && (
<section className="j13-module">
<p className="j13-module-title mb-3">
<Info size={15} style={{ color: "var(--accent)" }} /> 安全提示
</p>
<ul className="space-y-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
<li className="flex gap-2">
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
<span>修改密码后会立即在所有设备登出,需要重新登录。</span>
</li>
<li className="flex gap-2">
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
<span>昵称和头像会公开展示在帖子、评论和个人主页。</span>
</li>
<li className="flex gap-2">
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
<span>邮箱仅你自己可见,用于安全通知。</span>
</li>
</ul>
</section>
)}
</aside>
</div>
</div> </div>
); );
} }

View File

@@ -1,3 +1,7 @@
"use client";
import { useEffect, useState } from "react";
// 柔和多色头像:按用户名确定性取色,圆形软底,浅/暗主题在 globals.css 各自配色 // 柔和多色头像:按用户名确定性取色,圆形软底,浅/暗主题在 globals.css 各自配色
function hash(s: string) { function hash(s: string) {
let h = 0; let h = 0;
@@ -18,15 +22,26 @@ export default function Avatar({
}) { }) {
const idx = hash(name || "?") % 8; const idx = hash(name || "?") % 8;
const initial = (name || "?").trim().charAt(0).toUpperCase(); const initial = (name || "?").trim().charAt(0).toUpperCase();
// 图片 404(如附件已被彻底删除)时回退到字母头像,不显示裂图
const [imgFailed, setImgFailed] = useState(false);
// 切换到新的图片地址时重置失败状态
useEffect(() => setImgFailed(false), [src]);
const showImg = !!src && !imgFailed;
return ( return (
<span <span
className={`inline-flex items-center justify-center shrink-0 select-none font-bold rounded-full overflow-hidden av-${idx} ${className}`} className={`inline-flex items-center justify-center shrink-0 select-none font-bold rounded-full overflow-hidden av-${idx} ${className}`}
style={{ width: size, height: size, fontSize: size * 0.42 }} style={{ width: size, height: size, fontSize: size * 0.42 }}
aria-hidden="true" aria-hidden="true"
> >
{src ? ( {showImg ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={src} alt="" className="w-full h-full object-cover" /> <img
src={src}
alt=""
className="w-full h-full object-cover"
onError={() => setImgFailed(true)}
/>
) : ( ) : (
initial initial
)} )}

View File

@@ -0,0 +1,425 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Cropper, { type Area } from "react-easy-crop";
import "react-easy-crop/react-easy-crop.css";
import {
X, UploadCloud, ZoomIn, Check, Loader2, Images, RotateCcw, Trash2, AlertTriangle, Crop,
} from "lucide-react";
import {
apiMyMedia, apiUploadAvatar, apiUseAvatar, apiDeleteAttachment,
type MediaAttachment,
} from "@/lib/api";
import { getCroppedAvatarWebp } from "@/lib/cropImage";
interface AvatarCropModalProps {
open: boolean;
onClose: () => void;
currentAvatar: string;
/** 头像变更(新 URL)或被删除(空串)时触发 */
onChanged: (avatar: string) => void;
}
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
export default function AvatarCropModal({
open, onClose, currentAvatar, onChanged,
}: AvatarCropModalProps) {
const [media, setMedia] = useState<MediaAttachment[]>([]);
const [imageSrc, setImageSrc] = useState<string | null>(null); // blob: 或同源 /uploads URL
const [srcIsBlob, setSrcIsBlob] = useState(false);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [pixelCrop, setPixelCrop] = useState<Area | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
// 正在"直接选用"的历史头像 id(区别于裁剪上传,走 PUT /avatar/use)
const [usingId, setUsingId] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 打开时拉取媒体库
useEffect(() => {
if (!open) return;
setError("");
apiMyMedia()
.then((res) => setMedia(res.attachments || []))
.catch(() => setMedia([]));
}, [open]);
// 关闭时复位并释放本地 blob URL(媒体库的同源 URL 不能 revoke)
useEffect(() => {
if (!open) {
setImageSrc((prev) => {
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
return null;
});
setSrcIsBlob(false);
setCrop({ x: 0, y: 0 });
setZoom(1);
setPixelCrop(null);
setError("");
setBusy(false);
setConfirmDeleteId(null);
setUsingId(null);
}
}, [open]);
useEffect(() => () => {
if (confirmTimer.current) clearTimeout(confirmTimer.current);
}, []);
// ESC 关闭
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, busy, onClose]);
const onCropComplete = useCallback((_: Area, areaPixels: Area) => {
setPixelCrop(areaPixels);
}, []);
const enterCrop = (src: string, isBlob: boolean) => {
setError("");
setImageSrc(src);
setSrcIsBlob(isBlob);
setCrop({ x: 0, y: 0 });
setZoom(1);
};
/**
* 点击媒体库图片:
* - 成品头像(kind=avatar,本就是合规正方形 WebP):直接选用已有文件,
* 走 PUT /avatar/use,绝不重新裁剪上传产生重复文件;
* - 其他图片(kind=image,如帖子插图,可能非正方形):进入裁剪界面,
* 裁剪后才生成新的头像文件上传。
*/
const handlePick = async (item: MediaAttachment) => {
if (busy || item.url === currentAvatar) return;
if (item.kind === "image") {
enterCrop(item.url, false);
return;
}
setBusy(true);
setUsingId(item.id);
setError("");
try {
const res = await apiUseAvatar(item.url);
if (!res.url) {
setError(res.error || "设置失败,请重试");
return;
}
onChanged(res.url);
} catch {
setError("网络错误,请重试");
} finally {
setBusy(false);
setUsingId(null);
}
};
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("请选择图片文件(JPG / PNG / GIF / WebP)");
return;
}
if (file.size > SOURCE_MAX_BYTES) {
setError("原图不能超过 15MB,裁剪后的头像需在 2MB 以内");
return;
}
enterCrop(URL.createObjectURL(file), true);
};
const backToLibrary = () => {
if (imageSrc && srcIsBlob) URL.revokeObjectURL(imageSrc);
setImageSrc(null);
setSrcIsBlob(false);
setError("");
};
const confirmUpload = async () => {
if (!imageSrc || !pixelCrop || busy) return;
setBusy(true);
setError("");
try {
// 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB)
const blob = await getCroppedAvatarWebp(imageSrc, pixelCrop);
const res = await apiUploadAvatar(blob);
if (!res.url) {
setError(res.error || "上传失败,请重试");
return;
}
onChanged(res.url); // 父组件负责关窗、广播与刷新
} catch (err) {
setError(err instanceof Error ? err.message : "上传失败,请重试");
} finally {
setBusy(false);
}
};
const handleDelete = async (item: MediaAttachment) => {
if (busy) return;
// 两阶段确认:第一次点击进入红色确认态,3.5 秒内再点才执行
if (confirmDeleteId !== item.id) {
setConfirmDeleteId(item.id);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
confirmTimer.current = setTimeout(() => setConfirmDeleteId(null), 3500);
return;
}
if (confirmTimer.current) clearTimeout(confirmTimer.current);
setConfirmDeleteId(null);
setBusy(true);
setError("");
try {
const res = await apiDeleteAttachment(item.id);
if (res.ok === undefined && res.error) {
setError(res.error);
return;
}
setMedia((prev) => prev.filter((m) => m.id !== item.id));
// 删的正是当前头像:清空(回到字母头像)并关闭弹窗
if (item.url === currentAvatar) {
onChanged("");
}
} catch {
setError("网络错误,请重试");
} finally {
setBusy(false);
}
};
if (!open) return null;
return (
<div
className="fixed inset-0 z-[90] flex items-center justify-center p-4"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
onClick={() => !busy && onClose()}
>
<div
className="panel w-full max-w-[440px] rounded-2xl p-5"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="更换头像"
>
{/* 标题栏 */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-[15.5px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{imageSrc ? "裁剪头像" : "更换头像"}
</h3>
<button
type="button"
onClick={onClose}
disabled={busy}
aria-label="关闭"
className="inline-flex w-8 h-8 items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] disabled:opacity-50"
style={{ color: "var(--ink-3)" }}
>
<X size={17} />
</button>
</div>
{error && <div className="alert-error mb-4">{error}</div>}
{!imageSrc ? (
<>
{/* 选择新图片 */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex flex-col items-center justify-center gap-2 rounded-xl py-7 px-4 text-center transition-colors hover:border-[var(--accent)]"
style={{
border: "1.5px dashed var(--line-2)",
background: "var(--panel-2)",
color: "var(--ink-2)",
}}
>
<span
className="inline-flex w-11 h-11 items-center justify-center rounded-full"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<UploadCloud size={20} />
</span>
<span className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
点击选择图片
</span>
<span className="text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
支持 JPG / PNG / GIF / WebP,裁剪后统一转为 WebP,大小不超过 2MB
</span>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFileChange}
/>
{/* 媒体库:本人上传过的全部图片(头像 + 帖子插图) */}
{media.length > 0 && (
<div className="mt-5">
<p
className="flex items-center gap-1.5 text-[12px] font-bold tracking-[0.04em] mb-2.5"
style={{ color: "var(--ink-3)" }}
>
<Images size={13} /> 我的图片(历史头像点击直接使用,其他图片点击后裁剪;右上角可删除)
</p>
<div className="grid grid-cols-6 gap-2">
{media.map((item) => {
const active = item.url === currentAvatar;
const confirming = confirmDeleteId === item.id;
return (
<div
key={item.id}
// self-center:禁用网格项默认的 align-self:stretch,
// 否则它会与 aspect-ratio 冲突把容器拉高,光环变成竖椭圆
className="relative group rounded-full justify-self-center self-center w-full"
style={{
aspectRatio: "1 / 1",
boxShadow: active
? "0 0 0 2px var(--panel), 0 0 0 4px var(--accent)"
: "0 0 0 1px var(--line)",
}}
>
<button
type="button"
onClick={() => handlePick(item)}
disabled={busy || active}
title={
active
? "当前头像"
: item.kind === "image"
? "裁剪后设为头像"
: "直接设为头像(不会重复上传)"
}
// absolute inset-0:以正方形容器为确定包含块铺满,
// 避免 h-full 在 aspect-ratio 尺寸上的百分比解析歧义
className="absolute inset-0 rounded-full overflow-hidden bg-[var(--panel-2)] transition-transform hover:scale-105 disabled:cursor-default disabled:hover:scale-100"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="" className="w-full h-full object-cover" loading="lazy" />
</button>
{/* hover 指示:成品头像=直接使用,其他图片=裁剪;选用中显示 spinner */}
{!active && (
<span
className="pointer-events-none absolute inset-0 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: "color-mix(in srgb, var(--ink) 45%, transparent)", color: "#fff" }}
>
{usingId === item.id ? (
<Loader2 size={14} className="animate-spin" />
) : item.kind === "image" ? (
<Crop size={13} />
) : (
<Check size={14} strokeWidth={2.6} />
)}
</span>
)}
{/* 当前头像勾选标记 */}
{active && (
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full"
style={{ background: "color-mix(in srgb, var(--accent) 35%, transparent)", color: "#fff" }}
>
<Check size={15} strokeWidth={3} />
</span>
)}
{/* 删除按钮(两阶段确认) */}
{!active && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDelete(item);
}}
disabled={busy}
aria-label={confirming ? "再次点击确认删除" : "删除图片"}
title={confirming ? "再次点击确认彻底删除" : "彻底删除"}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
style={{
background: confirming ? "var(--red, #dc2626)" : "var(--panel)",
color: confirming ? "#fff" : "var(--ink-2)",
border: "1px solid " + (confirming ? "var(--red, #dc2626)" : "var(--line-2)"),
boxShadow: "0 1px 4px rgba(0,0,0,0.18)",
}}
>
{confirming ? <AlertTriangle size={11} /> : <Trash2 size={10.5} />}
</button>
)}
</div>
);
})}
</div>
</div>
)}
</>
) : (
<>
{/* 裁剪区 */}
<div
className="relative w-full h-72 rounded-xl overflow-hidden"
style={{ background: "#0c0d12" }}
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
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>
</div>
{/* 输出说明 + 操作 */}
<div className="flex items-center justify-between mt-5">
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
输出 320×320 WebP
</span>
<div className="flex items-center gap-2">
<button type="button" onClick={backToLibrary} disabled={busy} className="btn btn-line btn-sm">
<RotateCcw size={13.5} /> 重选
</button>
<button type="button" onClick={confirmUpload} disabled={busy} className="btn btn-primary btn-sm">
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
{busy ? "上传中" : "确认上传"}
</button>
</div>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Camera } from "lucide-react";
import Avatar from "@/components/Avatar";
import AvatarCropModal from "@/components/AvatarCropModal";
import { emitUserUpdate, onUserUpdate } from "@/lib/userEvents";
interface AvatarPickerProps {
name: string;
src: string;
size?: number;
}
/**
* 可点击的头像(资料设置表单内的唯一修改入口)。
*
* 同步策略(三条链路缺一不可):
* 1. 弹窗内上传/选用/删除成功 → 本组件 state 立即更新(本地即时反馈)
* 2. emitUserUpdate 广播 → Header 等同页其他客户端组件立即更新
* 3. router.refresh() → 服务端渲染区域(Hero、帖子列表等)重新取数
* 另外监听广播,使页面上若存在多个 picker 实例也保持一致;
* 并随 src prop(SSR 校正)同步,避免 useState 初始值造成数据漂移。
*/
export default function AvatarPicker({ name, src, size = 56 }: AvatarPickerProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState(src);
// SSR props 校正(router.refresh 后服务端数据回流)
useEffect(() => setCurrent(src), [src]);
// 同页其他位置(或另一实例)改了头像 → 同步本实例
useEffect(() => onUserUpdate((patch) => {
if (typeof patch.avatar === "string") setCurrent(patch.avatar);
}), []);
const handleChanged = (avatar: string) => {
setCurrent(avatar);
setOpen(false);
emitUserUpdate({ avatar });
router.refresh();
};
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="group relative inline-flex items-center justify-center shrink-0 p-0 rounded-full cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--panel)]"
style={{ width: size, height: size, lineHeight: 0 }}
aria-label="更换头像"
title="更换头像"
>
<Avatar name={name} src={current || undefined} size={size} />
{/* hover/focus 遮罩:与头像严格重合(按钮宽高固定 = size) */}
<span
className="absolute inset-0 m-0 rounded-full flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)", color: "#fff" }}
>
<Camera size={Math.round(size * 0.27)} />
<span className="text-[10px] font-bold leading-none">更换</span>
</span>
</button>
<AvatarCropModal
open={open}
onClose={() => setOpen(false)}
currentAvatar={current}
onChanged={handleChanged}
/>
</>
);
}

View File

@@ -200,7 +200,7 @@ export default function CommentSection({
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24"> <div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<Link href={`/u/${c.user.id}`} className="shrink-0" aria-label={c.user.nickname || c.user.username}> <Link href={`/u/${c.user.id}`} className="shrink-0" aria-label={c.user.nickname || c.user.username}>
<Avatar name={c.user.nickname || c.user.username} size={28} /> <Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={28} />
</Link> </Link>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
@@ -280,7 +280,7 @@ export default function CommentSection({
> >
<div className="flex gap-3"> <div className="flex gap-3">
<Link href={`/u/${c.user.id}`} className="shrink-0 mt-0.5" aria-label={c.user.nickname || c.user.username}> <Link href={`/u/${c.user.id}`} className="shrink-0 mt-0.5" aria-label={c.user.nickname || c.user.username}>
<Avatar name={c.user.nickname || c.user.username} size={38} /> <Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={38} />
</Link> </Link>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-1.5"> <div className="flex items-center gap-2.5 mb-1.5">

View File

@@ -19,9 +19,11 @@ import {
User as UserIcon, User as UserIcon,
ChevronDown, ChevronDown,
Megaphone, Megaphone,
Palette,
} from "lucide-react"; } from "lucide-react";
import { apiLogout, apiMe, type User } from "@/lib/api"; import { apiLogout, apiMe, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies"; import { hasAuthCookieHint } from "@/lib/cookies";
import { onUserUpdate } from "@/lib/userEvents";
import NotificationBell from "./NotificationBell"; import NotificationBell from "./NotificationBell";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
@@ -53,6 +55,11 @@ export default function Header({
setUnread(initialUnread); setUnread(initialUnread);
}, [initialUnread]); }, [initialUnread]);
// 同页内资料变更(如上传/删除头像)立即生效,不依赖 router.refresh 时序
useEffect(() => onUserUpdate((patch) => {
setUser((prev) => (prev ? { ...prev, ...patch } : prev));
}), []);
// 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用、旧 Path refresh // 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用、旧 Path refresh
// 半登录等边缘情况。正常 F5 下结果与 SSR 一致,不产生可见闪动; // 半登录等边缘情况。正常 F5 下结果与 SSR 一致,不产生可见闪动;
// 无可读 CSRF cookie 说明必然是游客,跳过请求 // 无可读 CSRF cookie 说明必然是游客,跳过请求
@@ -274,9 +281,14 @@ export default function Header({
)} )}
{user && <MobileLink href="/settings">账号设置</MobileLink>} {user && <MobileLink href="/settings">账号设置</MobileLink>}
{user?.role === "admin" && ( {user?.role === "admin" && (
<MobileLink href="/admin/announcements"> <>
<span className="inline-flex items-center gap-2"><Megaphone size={15} /> 公告管理</span> <MobileLink href="/admin/announcements">
</MobileLink> <span className="inline-flex items-center gap-2"><Megaphone size={15} /> 公告管理</span>
</MobileLink>
<MobileLink href="/admin/appearance">
<span className="inline-flex items-center gap-2"><Palette size={15} /> 外观设置</span>
</MobileLink>
</>
)} )}
{!user && <MobileLink href="/login">登录</MobileLink>} {!user && <MobileLink href="/login">登录</MobileLink>}
{!user && <MobileLink href="/register">注册</MobileLink>} {!user && <MobileLink href="/register">注册</MobileLink>}
@@ -373,7 +385,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
}, [open]); }, [open]);
const itemCls = const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] font-semibold transition-colors hover:bg-[var(--panel-2)]"; "w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] font-semibold transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
return ( return (
<div className="relative hidden lg:block" ref={menuRef}> <div className="relative hidden lg:block" ref={menuRef}>
@@ -387,7 +399,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
background: open ? "var(--accent-soft)" : "var(--panel)", background: open ? "var(--accent-soft)" : "var(--panel)",
}} }}
> >
<Avatar name={user.nickname || user.username} size={28} /> <Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
<span <span
className="text-[13px] font-semibold max-w-[90px] truncate" className="text-[13px] font-semibold max-w-[90px] truncate"
style={{ color: open ? "var(--accent)" : "var(--ink)" }} style={{ color: open ? "var(--accent)" : "var(--ink)" }}
@@ -419,7 +431,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
className="flex items-center gap-3 px-4 py-3.5" className="flex items-center gap-3 px-4 py-3.5"
style={{ borderBottom: "1px solid var(--line)" }} style={{ borderBottom: "1px solid var(--line)" }}
> >
<Avatar name={user.nickname || user.username} size={38} /> <Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[14px] font-bold truncate" style={{ color: "var(--ink)" }}> <p className="text-[14px] font-bold truncate" style={{ color: "var(--ink)" }}>
{user.nickname} {user.nickname}
@@ -440,6 +452,9 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
<Link href="/admin/announcements" onClick={() => setOpen(false)} className={itemCls} role="menuitem"> <Link href="/admin/announcements" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 公告管理 <Megaphone size={15} style={{ color: "var(--accent)" }} /> 公告管理
</Link> </Link>
<Link href="/admin/appearance" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Palette size={15} style={{ color: "var(--accent)" }} /> 外观设置
</Link>
</div> </div>
)} )}
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}> <div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>

View File

@@ -55,7 +55,7 @@ export default function MobilePostBar({
<button <button
type="button" type="button"
onClick={scrollToComments} onClick={scrollToComments}
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]" className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-2)" }} style={{ color: "var(--ink-2)" }}
aria-label="跳到评论框" aria-label="跳到评论框"
> >
@@ -69,7 +69,7 @@ export default function MobilePostBar({
<button <button
type="button" type="button"
onClick={scrollToTop} onClick={scrollToTop}
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] j13-toast-in" className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] j13-toast-in"
style={{ color: "var(--ink-2)" }} style={{ color: "var(--ink-2)" }}
aria-label="回到顶部" aria-label="回到顶部"
> >

View File

@@ -163,10 +163,11 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
<button <button
key={n.id} key={n.id}
onClick={() => handleClickNotification(n)} onClick={() => handleClickNotification(n)}
className="w-full text-left px-4 py-3.5 flex items-start gap-3 transition-colors" className={`w-full text-left px-4 py-3.5 flex items-start gap-3 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] ${
n.is_read ? "" : "bg-[var(--panel-2)]"
}`}
style={{ style={{
borderBottom: "1px solid var(--line)", borderBottom: "1px solid var(--line)",
background: n.is_read ? "transparent" : "var(--panel-2)",
}} }}
> >
<span <span

View File

@@ -44,13 +44,13 @@ export default function PinnedPosts({
<Link <Link
key={p.id} key={p.id}
href={`/post/${p.id}`} href={`/post/${p.id}`}
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--panel-2)] j13-fade-up" className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] j13-fade-up"
style={{ style={{
...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}), ...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}),
animationDelay: `${i * 35}ms`, animationDelay: `${i * 35}ms`,
}} }}
> >
<Avatar name={p.user.nickname || p.user.username} size={36} /> <Avatar name={p.user.nickname || p.user.username} src={p.user.avatar || undefined} size={36} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 <h3
className="text-[14.5px] font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)]" className="text-[14.5px] font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)]"

View File

@@ -102,7 +102,7 @@ export default function PostActions({ postId, authorId, pinned, recommended }: P
if (!canManage) return null; if (!canManage) return null;
const itemCls = const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--panel-2)]"; "w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
return ( return (
<div className="relative" ref={menuRef}> <div className="relative" ref={menuRef}>

View File

@@ -31,7 +31,7 @@ export default function PostRow({
return ( return (
<div <div
className={`group relative flex items-center px-4 sm:px-5 border-t transition-colors hover:bg-[var(--panel-2)] j13-fade-up ${ className={`group relative flex items-center px-4 sm:px-5 border-t transition-colors hover:bg-[var(--accent-soft)] focus-within:bg-[var(--accent-soft)] j13-fade-up ${
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4" compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
}`} }`}
style={{ style={{
@@ -47,7 +47,7 @@ export default function PostRow({
aria-label={post.user.nickname} aria-label={post.user.nickname}
tabIndex={-1} tabIndex={-1}
> >
<Avatar name={post.user.nickname || post.user.username} size={compact ? 38 : 46} /> <Avatar name={post.user.nickname || post.user.username} src={post.user.avatar || undefined} size={compact ? 38 : 46} />
</Link> </Link>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View File

@@ -113,7 +113,7 @@ function Announcements({ items }: { items: AnnouncementSummary[] }) {
<li key={a.id}> <li key={a.id}>
<Link <Link
href={`/announcement/${a.id}`} href={`/announcement/${a.id}`}
className="group block -mx-1.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-[var(--panel-2)]" className="group block -mx-1.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
> >
<span className="flex items-center justify-between gap-2"> <span className="flex items-center justify-between gap-2">
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span> <span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
@@ -194,7 +194,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
<li key={u.id}> <li key={u.id}>
<Link <Link
href={`/u/${u.id}`} href={`/u/${u.id}`}
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--panel-2)]" className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
> >
<Avatar name={name} src={u.avatar} size={38} /> <Avatar name={name} src={u.avatar} size={38} />
<span <span

View File

@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { PenSquare, Home } from "lucide-react"; import { PenSquare, Home, ChevronRight } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api"; import type { BoardWithCount, User } from "@/lib/api";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台 // 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
@@ -23,64 +23,62 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user:
<PenSquare size={16} /> 发起讨论 <PenSquare size={16} /> 发起讨论
</Link> </Link>
{/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示 */} {/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示;卡片样式与下方板块一致 */}
<div className="space-y-0.5"> <div className="space-y-0.5">
<Link <Link href="/" className="j13-bcard" aria-current={activeBoard === 0 ? "page" : undefined}>
href="/" <span
className="j13-raillink" className="j13-bcard-ico"
aria-current={activeBoard === 0 ? "page" : undefined} style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
> aria-hidden="true"
<Home size={16} className="shrink-0" /> >
<span className="flex-1 truncate">全部讨论</span> <Home size={16} />
</span>
<span className="j13-bcard-name flex-1 truncate text-[13px] font-bold">
全部讨论
</span>
</Link> </Link>
</div> </div>
{/* 板块目录:发现型入口(介绍 + 讨论量),区别于帖子页面包屑的位置指引; {/* 板块目录:标题可点击进入板块汇总列表页(/boards);
点击不离开工作台,仅在中栏筛选该板块帖子流 */} 下方板块卡片点击不离开工作台,仅在中栏筛选该板块帖子流 */}
<div> <div>
<p <Link
className="px-1 mb-2 flex items-center justify-between text-[11.5px] font-bold tracking-[0.08em] uppercase" href="/boards"
style={{ color: "var(--ink-3)" }} className="group px-1 mb-2 flex items-center justify-between text-[11.5px] font-bold tracking-[0.08em] uppercase text-[var(--ink-3)] hover:text-[var(--accent)] transition-colors"
aria-label="进入板块目录列表页"
> >
<span>板块目录</span> <span className="flex items-center">
<span>板块目录</span>
<ChevronRight
size={12}
className="ml-0.5 opacity-0 -translate-x-0.5 group-hover:opacity-100 group-hover:translate-x-0 transition-all"
aria-hidden="true"
/>
</span>
{boards.length > 0 && ( {boards.length > 0 && (
<span className="tracking-normal font-semibold tabular-nums">{boards.length} 个</span> <span className="tracking-normal font-semibold tabular-nums">{boards.length} 个</span>
)} )}
</p> </Link>
<div className="space-y-2"> <div className="space-y-2">
{boards.map((b, i) => { {boards.map((b, i) => (
const desc = b.description?.trim(); <Link
return ( key={b.id}
<Link href={boardHref(b.id)}
key={b.id} className="j13-bcard"
href={boardHref(b.id)} aria-current={activeBoard === b.id ? "page" : undefined}
className="j13-bcard" aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
aria-current={activeBoard === b.id ? "page" : undefined} >
aria-label={`${b.name}板块,${b.post_count} 篇讨论`} <span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
> {b.name.slice(0, 1)}
<span </span>
className={`av-${i % 8} j13-bcard-ico`} <span className="min-w-0 flex-1 flex items-center justify-between gap-2">
aria-hidden="true" <span className="j13-bcard-name truncate text-[13px] font-bold">
> {b.name}
{b.name.slice(0, 1)}
</span> </span>
<span className="min-w-0 flex-1"> <span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
<span className="flex items-center justify-between gap-2"> </span>
<span </Link>
className="truncate text-[13px] font-bold" ))}
style={{ color: "var(--ink)" }}
>
{b.name}
</span>
<span className="meta shrink-0 text-[11px] tabular-nums">
{b.post_count} 篇
</span>
</span>
{desc && <span className="j13-bcard-desc">{desc}</span>}
</span>
</Link>
);
})}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -349,12 +349,28 @@ export async function fetchOverview(cookieHeader?: string): Promise<OverviewResp
return res.json(); return res.json();
} }
export async function fetchBoards(): Promise<{ boards: Board[] }> { export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
const res = await fetch(`${API_BASE}/api/boards`, ssrInit()); const res = await fetch(`${API_BASE}/api/boards`, ssrInit());
if (!res.ok) throw new Error("获取板块失败"); if (!res.ok) throw new Error("获取板块失败");
return res.json(); return res.json();
} }
// 公开站点设置:accent 为站点主题色 hex(空串=内置默认)
export interface PublicSettings {
accent: string;
}
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
export async function fetchPublicSettings(): Promise<PublicSettings> {
try {
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
if (!res.ok) return { accent: "" };
return (await res.json()) as PublicSettings;
} catch {
return { accent: "" };
}
}
export async function fetchPostDetail(id: string, cookieHeader?: string): Promise<{ post: Post }> { export async function fetchPostDetail(id: string, cookieHeader?: string): Promise<{ post: Post }> {
const res = await fetch(`${API_BASE}/api/posts/${id}`, ssrInit(cookieHeaders(cookieHeader))); const res = await fetch(`${API_BASE}/api/posts/${id}`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("获取帖子失败"); if (!res.ok) throw new Error("获取帖子失败");
@@ -517,7 +533,6 @@ export async function apiChangePassword(oldPassword: string, newPassword: string
// 更新当前用户资料(昵称、头像、邮箱、签名) // 更新当前用户资料(昵称、头像、邮箱、签名)
export async function apiUpdateProfile(data: { export async function apiUpdateProfile(data: {
nickname: string; nickname: string;
avatar?: string;
email?: string; email?: string;
signature?: string; signature?: string;
}): Promise<{ user?: User; error?: string }> { }): Promise<{ user?: User; error?: string }> {
@@ -529,6 +544,66 @@ export async function apiUpdateProfile(data: {
return res.json(); return res.json();
} }
// 媒体库附件(avatar = 头像,image = 帖子插图等)
export interface MediaAttachment {
id: number;
url: string;
kind: "avatar" | "image";
size: number;
width: number;
height: number;
created_at: string;
}
// 上传裁剪后的头像(Blob 必须是 WebP;不要手动设置 Content-Type,交给浏览器加 multipart 边界)
export async function apiUploadAvatar(
file: Blob
): Promise<{ url?: string; error?: string }> {
const form = new FormData();
form.append("file", file, "avatar.webp");
const res = await fetchWithRefresh("/api/upload/avatar", {
method: "POST",
headers: clientHeaders(),
body: form,
});
return res.json();
}
// 选用一张本人历史上传的头像
export async function apiUseAvatar(
url: string
): Promise<{ url?: string; error?: string }> {
const res = await fetchWithRefresh("/api/avatar/use", {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ url }),
});
return res.json();
}
// 媒体库:本人上传的全部图片
export async function apiMyMedia(): Promise<{
attachments?: MediaAttachment[];
error?: string;
}> {
const res = await fetchWithRefresh("/api/my/media", {
method: "GET",
headers: clientHeaders(),
});
return res.json();
}
// 彻底删除本人附件
export async function apiDeleteAttachment(
id: number
): Promise<{ ok?: boolean; error?: string }> {
const res = await fetchWithRefresh(`/api/my/attachments/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
return res.json();
}
export async function apiToggleLike(postId: string) { export async function apiToggleLike(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/like`, { const res = await fetchWithRefresh(`/api/posts/${postId}/like`, {
method: "POST", method: "POST",
@@ -663,3 +738,23 @@ export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
throw new Error(data.error || "删除公告失败"); throw new Error(data.error || "删除公告失败");
} }
} }
// ===== 站点外观设置 =====
export async function apiGetSettings(): Promise<PublicSettings> {
const res = await fetchWithRefresh("/api/settings", { method: "GET" });
if (!res.ok) throw new Error("获取站点设置失败");
return res.json();
}
// 更新站点主题色;accent 传空串恢复默认
export async function apiUpdateSiteSettings(accent: string): Promise<PublicSettings> {
const res = await fetchWithRefresh("/api/admin/settings", {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ accent }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存设置失败");
return { accent: data.accent ?? "" };
}

59
frontend/lib/cropImage.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { Area } from "react-easy-crop";
// 头像裁剪输出尺寸(正方形)。头像最大展示 88px,320px 足够覆盖高分屏
export const AVATAR_OUTPUT_SIZE = 320;
// 裁剪后 WebP 文件大小上限(与后端 service.AvatarMaxBytes 保持一致)
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.addEventListener("load", () => resolve(img));
img.addEventListener("error", () => reject(new Error("图片加载失败,请换一张试试")));
img.src = src;
});
}
function canvasToWebp(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob(resolve, "image/webp", quality));
}
/**
* 按裁剪区域导出正方形 WebP。
* 自动按 0.92 → 0.65 降质量,直到满足大小上限;仍超限则抛出错误。
*/
export async function getCroppedAvatarWebp(
imageSrc: string,
pixelCrop: Area
): Promise<Blob> {
const image = await loadImage(imageSrc);
const canvas = document.createElement("canvas");
canvas.width = AVATAR_OUTPUT_SIZE;
canvas.height = AVATAR_OUTPUT_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("当前浏览器不支持图片裁剪");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
AVATAR_OUTPUT_SIZE,
AVATAR_OUTPUT_SIZE
);
let last: Blob | null = null;
for (const quality of [0.92, 0.85, 0.75, 0.65]) {
const blob = await canvasToWebp(canvas, quality);
if (!blob) throw new Error("当前浏览器不支持 WebP 导出,请使用最新版 Chrome/Edge/Safari");
last = blob;
if (blob.size <= AVATAR_MAX_BYTES) return blob;
}
if (last && last.size <= AVATAR_MAX_BYTES) return last;
throw new Error("裁剪后的图片仍超过 2MB,请缩小图片后再试");
}

View File

@@ -1,6 +1,9 @@
import { cache } from "react"; import { cache } from "react";
import { fetchMe } from "./api"; import { fetchMe, fetchPublicSettings } from "./api";
// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户, // 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户,
// 相同 Cookie 头参数下只产生一次 /api/me 调用。 // 相同 Cookie 头参数下只产生一次 /api/me 调用。
export const getMeCached = cache((cookieHeader?: string) => fetchMe(cookieHeader)); export const getMeCached = cache((cookieHeader?: string) => fetchMe(cookieHeader));
// 同请求内去重:layout 注入主题 CSS 变量、后台页读取当前配置共用
export const getPublicSettingsCached = cache(() => fetchPublicSettings());

243
frontend/lib/theme.ts Normal file
View File

@@ -0,0 +1,243 @@
// 站点主题色工具库(SSR / 客户端同构)
//
// 设计:
// - 后端只存一个浅色基准 hex(site_settings.theme_accent,空=默认靛蓝)。
// - 所有 accent 相关 CSS 变量由本模块从该 hex 派生:浅色一套、暗色一套。
// - 自定义色经 WCAG AA 自校正(相对白色 / 暗面板对比度 ≥4.5),保证选中底、
// 按钮白字、暗色文字在任何主题色下都可读,不需要管理员具备配色知识。
// - 派生变量通过 SSR 内联 <style> 注入,覆盖 globals.css 中的默认值,无 FOUC。
// ---------------------------------------------------------------
// 预设色板(成对调校:浅色已验证对白色 ≥4.5,暗色对 #181b21/#20242c ≥4.5)
// ---------------------------------------------------------------
export interface AccentPreset {
key: string;
name: string;
light: string;
dark: string;
}
// 内置默认(与 globals.css 默认值一致,后端未配置时不注入覆盖样式)
export const DEFAULT_ACCENT = "#3b5bdb";
export const ACCENT_PRESETS: AccentPreset[] = [
{ key: "indigo", name: "靛蓝", light: "#3b5bdb", dark: "#8ea2ff" },
{ key: "emerald", name: "翡翠绿", light: "#05875f", dark: "#34d399" },
{ key: "violet", name: "紫罗兰", light: "#6d28d9", dark: "#a78bfa" },
{ key: "orange", name: "赤陶橙", light: "#c2410c", dark: "#fb923c" },
{ key: "rose", name: "玫红", light: "#be123c", dark: "#fb7185" },
{ key: "sky", name: "天青蓝", light: "#0369a1", dark: "#38bdf8" },
];
// 按浅色 hex 找对应预设(自定义色返回 null)
export function findPreset(hex: string): AccentPreset | null {
const h = hex.toLowerCase();
return ACCENT_PRESETS.find((p) => p.light === h) ?? null;
}
// ---------------------------------------------------------------
// 颜色数学
// ---------------------------------------------------------------
type RGB = [number, number, number];
type HSL = [number, number, number];
function hexToRgb(hex: string): RGB {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex([r, g, b]: RGB): string {
return (
"#" +
[r, g, b]
.map((v) => Math.round(v).toString(16).padStart(2, "0"))
.join("")
);
}
// 校验并归一化为 #rrggbb;非法返回 null(支持省略 # 与 3 位简写)
export function normalizeHex(input: string): string | null {
let s = input.trim().toLowerCase();
if (!s) return null;
if (!s.startsWith("#")) s = "#" + s;
if (/^#[0-9a-f]{3}$/.test(s)) {
s = "#" + s[1].repeat(2) + s[2].repeat(2) + s[3].repeat(2);
}
return /^#[0-9a-f]{6}$/.test(s) ? s : null;
}
function srgbLin(c: number): number {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function luminance([r, g, b]: RGB): number {
return 0.2126 * srgbLin(r) + 0.7152 * srgbLin(g) + 0.0722 * srgbLin(b);
}
// WCAG 对比度(hex 对 hex)
export function contrastRatio(a: string, b: string): number {
const la = luminance(hexToRgb(a));
const lb = luminance(hexToRgb(b));
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
function rgbToHsl([r, g, b]: RGB): HSL {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return [h, s, l];
}
function hslToRgb([h, s, l]: HSL): RGB {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
}
// 与指定颜色混合:weight=1 返回目标色,weight=0 返回基色
function mix(from: string, toward: string, weight: number): string {
const a = hexToRgb(from);
const b = hexToRgb(toward);
return rgbToHex([
a[0] + (b[0] - a[0]) * weight,
a[1] + (b[1] - a[1]) * weight,
a[2] + (b[2] - a[2]) * weight,
]);
}
function rgba(hex: string, alpha: number): string {
const [r, g, b] = hexToRgb(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// ---------------------------------------------------------------
// AA 自校正
// ---------------------------------------------------------------
// 浅色模式基准色:相对白色对比度不足(多为粉彩/荧光)时逐步降亮度至 ≥4.5
function clampForLight(hex: string): string {
const [h, s, lRaw] = rgbToHsl(hexToRgb(hex));
let l = lRaw;
let cur = hex;
for (let i = 0; i < 120; i++) {
cur = rgbToHex(hslToRgb([h, s, l]));
if (contrastRatio(cur, "#ffffff") >= 4.5) return cur;
l -= 0.01;
}
return cur;
}
// 暗色模式伴随色:同色相提亮,至对暗面板与深色压字全部 ≥4.5
function deriveDark(hex: string): string {
const [h, s, lRaw] = rgbToHsl(hexToRgb(hex));
let l = Math.max(lRaw, 0.62);
const surfaces = ["#181b21", "#0f1115", "#10162b"];
let cur = hex;
for (let i = 0; i < 120; i++) {
cur = rgbToHex(hslToRgb([h, s, l]));
if (surfaces.every((sf) => contrastRatio(cur, sf) >= 4.5)) return cur;
l = Math.min(0.86, l + 0.01);
}
return cur;
}
// ---------------------------------------------------------------
// 派生 CSS 变量
// ---------------------------------------------------------------
export interface AccentDerivation {
light: string; // 校正后的浅色基准
dark: string; // 暗色模式伴随色
adjusted: boolean; // 自定义色是否经过自动校正(UI 可提示)
}
// 从任意输入 hex 派生浅/暗两套主题色;预设色直接采用调校好的成对值
export function deriveAccentPair(input: string): AccentDerivation {
const hex = normalizeHex(input);
if (!hex) {
return { light: DEFAULT_ACCENT, dark: ACCENT_PRESETS[0].dark, adjusted: false };
}
const preset = findPreset(hex);
if (preset) return { light: preset.light, dark: preset.dark, adjusted: false };
const light = clampForLight(hex);
return { light, dark: deriveDark(hex), adjusted: light !== hex };
}
// 生成可直接注入的 CSS 文本(覆盖 globals.css 默认 accent 令牌);
// 未配置或等于默认色时返回空串,完全使用内置样式
export function accentStyleCSS(input: string): string {
if (!input || input.toLowerCase() === DEFAULT_ACCENT) return "";
const { light, dark } = deriveAccentPair(input);
const hi = mix(light, "#000000", 0.1);
const darkHi = mix(dark, "#ffffff", 0.14);
return [
`:root{`,
`--accent:${light};`,
`--accent-hi:${hi};`,
`--accent-soft:${rgba(light, 0.1)};`,
`--accent-on:#ffffff;`,
`--nav-active-bg:${rgba(light, 0.1)};`,
`--nav-active-ink:${light};`,
`--nav-active-line:${rgba(light, 0.22)};`,
`}`,
`.dark{`,
`--accent:${dark};`,
`--accent-hi:${darkHi};`,
`--accent-soft:${rgba(dark, 0.16)};`,
`--accent-on:#10162b;`,
`--nav-active-bg:${rgba(dark, 0.14)};`,
`--nav-active-ink:${darkHi};`,
`--nav-active-line:${rgba(dark, 0.3)};`,
`}`,
].join("");
}
// ---------------------------------------------------------------
// 客户端:实时换肤(后台预览 / 保存后即时生效)
// ---------------------------------------------------------------
const STYLE_ELEMENT_ID = "j13-accent-vars";
// 将主题色应用到当前文档;传 null 移除覆盖、恢复内置默认
export function applyAccentTheme(input: string | null): void {
if (typeof document === "undefined") return;
let el = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;
const css = input === null ? "" : accentStyleCSS(input);
if (!css) {
el?.remove();
return;
}
if (!el) {
el = document.createElement("style");
el.id = STYLE_ELEMENT_ID;
el.setAttribute("data-accent-source", "runtime");
document.head.appendChild(el);
}
el.textContent = css;
}

View File

@@ -0,0 +1,29 @@
import type { User } from "./api";
/**
* 全局用户状态事件总线。
*
* 背景:Header 等客户端组件挂载在持久化的 root layout 上,仅靠
* router.refresh() 刷新 props 在上传/删除头像等场景下存在时序与
* Router Cache 的不确定性。同页内的数据更新必须走显式的状态链路
* (React 状态/事件),storage 事件、轮询等方案对本标签页无效。
*
* 任意位置修改了当前用户资料后 dispatch 一个补丁,Header 等订阅方
* 立即合并更新;router.refresh() 仍照常调用,负责校正服务端渲染区域。
*/
export const USER_UPDATED_EVENT = "j13:user-updated";
export function emitUserUpdate(patch: Partial<User>): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(USER_UPDATED_EVENT, { detail: patch }));
}
export function onUserUpdate(handler: (patch: Partial<User>) => void): () => void {
if (typeof window === "undefined") return () => {};
const listener = (e: Event) => {
const detail = (e as CustomEvent<Partial<User>>).detail;
if (detail) handler(detail);
};
window.addEventListener(USER_UPDATED_EVENT, listener);
return () => window.removeEventListener(USER_UPDATED_EVENT, listener);
}

View File

@@ -1,5 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
// 后端地址:开发环境兜底 localhost:3001,生产部署必须显式配置 BACKEND_URL
const API_TARGET =
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
images: { images: {
minimumCacheTTL: 14400, // 4 小时 minimumCacheTTL: 14400, // 4 小时
@@ -11,7 +15,12 @@ const nextConfig: NextConfig = {
return [ return [
{ {
source: "/api/:path*", source: "/api/:path*",
destination: "http://localhost:3001/api/:path*", destination: `${API_TARGET}/api/:path*`,
},
// 用户上传文件(头像等,全部 WebP)
{
source: "/uploads/:path*",
destination: `${API_TARGET}/uploads/:path*`,
}, },
]; ];
}, },

View File

@@ -12,6 +12,7 @@
"next": "^16.0.0", "next": "^16.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-easy-crop": "^6.2.3",
"react-markdown": "^9.0.1" "react-markdown": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -2509,6 +2510,12 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/parse-entities": { "node_modules/parse-entities": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -2600,6 +2607,19 @@
"react": "^19.3.0" "react": "^19.3.0"
} }
}, },
"node_modules/react-easy-crop": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.3.tgz",
"integrity": "sha512-ebimG3OGlzizjxEZ77Cj9CVLcg5vDZ6QVz8ud1rx4WmsCDWHIROEhgMi0GpJ/jKAuwHrH0M+QZUK4hyvxbYhOA==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-markdown": { "node_modules/react-markdown": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",

View File

@@ -9,18 +9,19 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^0.460.0",
"next": "^16.0.0", "next": "^16.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"lucide-react": "^0.460.0", "react-easy-crop": "^6.2.3",
"react-markdown": "^9.0.1" "react-markdown": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }
} }