feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化: 1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示 2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制 3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频 4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化 5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
111
backend/handler/announcement.go
Normal file
111
backend/handler/announcement.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 公开接口 =====
|
||||||
|
|
||||||
|
// AnnouncementsList 已发布公告列表(首页右栏 / 公告页)
|
||||||
|
func (h *Handlers) AnnouncementsList(c *gin.Context) {
|
||||||
|
list, err := h.Announcement.ListPublished(10)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"announcements": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnnouncementDetail 已发布公告详情
|
||||||
|
func (h *Handlers) AnnouncementDetail(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的公告 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, err := h.Announcement.GetPublished(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "公告不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"announcement": a})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 管理接口(RequireAdmin 兜底,前端不做权限判定) =====
|
||||||
|
|
||||||
|
// AdminListAnnouncements 全部公告(含草稿)
|
||||||
|
func (h *Handlers) AdminListAnnouncements(c *gin.Context) {
|
||||||
|
list, err := h.Announcement.ListAll()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"announcements": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateAnnouncement 新建公告
|
||||||
|
func (h *Handlers) AdminCreateAnnouncement(c *gin.Context) {
|
||||||
|
var in service.AnnouncementInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, err := h.Announcement.Create(&in)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"announcement": a})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateAnnouncement 编辑公告
|
||||||
|
func (h *Handlers) AdminUpdateAnnouncement(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的公告 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in service.AnnouncementInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, err := h.Announcement.Update(uint(id), &in)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "公告不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"announcement": a})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteAnnouncement 删除公告
|
||||||
|
func (h *Handlers) AdminDeleteAnnouncement(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的公告 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Announcement.Delete(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "公告不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ type LoginRequest struct {
|
|||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register 用户注册
|
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
|
||||||
func (h *Handlers) Register(c *gin.Context) {
|
func (h *Handlers) Register(c *gin.Context) {
|
||||||
var req RegisterRequest
|
var req RegisterRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -115,10 +115,26 @@ func (h *Handlers) Register(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 自动登录:走与 Login 相同的 token 签发链路
|
||||||
|
accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
// 签发失败不应让注册白做:仍返回成功,让前端引导去登录页
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user": gin.H{
|
||||||
|
"id": loginUser.ID,
|
||||||
|
"username": loginUser.Username,
|
||||||
|
"nickname": loginUser.Nickname,
|
||||||
|
"avatar": loginUser.Avatar,
|
||||||
|
"role": loginUser.Role,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 用户登录:access + refresh token 写入 HttpOnly cookie,CSRF 写入可读 cookie
|
// Login 用户登录:access + refresh token 写入 HttpOnly cookie,CSRF 写入可读 cookie
|
||||||
|
|||||||
@@ -9,27 +9,37 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostComments 获取帖子评论
|
// PostComments 获取帖子评论树(楼层分页:主评论算楼层,每楼附带全部子回复,时间正序)
|
||||||
func (h *Handlers) PostComments(c *gin.Context) {
|
func (h *Handlers) PostComments(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
comments, err := h.Comment.ListByPost(uint(id))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
|
||||||
|
comments, floors, totalComments, err := h.Comment.ListFloorPaged(uint(id), page, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"comments": comments})
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"comments": comments,
|
||||||
|
"total": floors, // 楼层数(主评论数)→ 前端分页与楼层号计算
|
||||||
|
"total_comments": totalComments, // 全部评论数(含回复)→ 展示徽标
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCommentRequest 评论请求
|
// CreateCommentRequest 评论请求
|
||||||
type CreateCommentRequest struct {
|
type CreateCommentRequest struct {
|
||||||
Content string `json:"content" binding:"required,min=1"`
|
Content string `json:"content" binding:"required,min=1"`
|
||||||
|
ParentID *uint `json:"parent_id"` // 非空 = 回复该评论(发为其子评论)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateComment 创建评论
|
// CreateComment 创建评论(主评论或子回复)
|
||||||
func (h *Handlers) CreateComment(c *gin.Context) {
|
func (h *Handlers) CreateComment(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
@@ -42,15 +52,20 @@ func (h *Handlers) CreateComment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
comment, err := h.Comment.Create(claims.ID, uint(id), req.Content)
|
comment, parent, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID)
|
||||||
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
|
||||||
}
|
}
|
||||||
// 通知帖子作者(排除自己评论自己的帖子)
|
if parent == nil {
|
||||||
|
// 主评论:通知帖子作者(排除自己评论自己的帖子)
|
||||||
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
|
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
|
||||||
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
|
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 子回复:通知父评论作者(Notification.Create 内部排除自我通知)
|
||||||
|
h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"comment": comment})
|
c.JSON(http.StatusOK, gin.H{"comment": comment})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ type Handlers struct {
|
|||||||
Notification *service.NotificationService
|
Notification *service.NotificationService
|
||||||
OverviewSvc *service.OverviewService
|
OverviewSvc *service.OverviewService
|
||||||
Checkin *service.CheckinService
|
Checkin *service.CheckinService
|
||||||
|
Announcement *service.AnnouncementService
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (h *Handlers) Overview(c *gin.Context) {
|
|||||||
"hot": data.Hot,
|
"hot": data.Hot,
|
||||||
"active_users": data.ActiveUsers,
|
"active_users": data.ActiveUsers,
|
||||||
"boards": data.Boards,
|
"boards": data.Boards,
|
||||||
"latest_comments": data.LatestComments,
|
"announcements": data.Announcements,
|
||||||
"new_users": data.NewUsers,
|
"new_users": data.NewUsers,
|
||||||
"checkin": data.Checkin,
|
"checkin": data.Checkin,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 站点公告
|
||||||
|
var anns []model.Announcement
|
||||||
|
model.DB.Where("published = ?", true).Order("created_at DESC").Limit(200).Find(&anns)
|
||||||
|
for _, a := range anns {
|
||||||
|
entries = append(entries, urlEntry{
|
||||||
|
Loc: fmt.Sprintf("%s/announcement/%d", siteBaseURL, a.ID),
|
||||||
|
Lastmod: a.UpdatedAt.Format("2006-01-02"),
|
||||||
|
Changefreq: "weekly",
|
||||||
|
Priority: "0.5",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
us := urlset{
|
us := urlset{
|
||||||
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||||
URLs: entries,
|
URLs: entries,
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
|||||||
postCount, _ := h.Post.CountByUser(user.ID)
|
postCount, _ := h.Post.CountByUser(user.ID)
|
||||||
commentCount, _ := h.Comment.CountByUser(user.ID)
|
commentCount, _ := h.Comment.CountByUser(user.ID)
|
||||||
|
|
||||||
|
// 签到积分概览(失败不阻塞资料返回)
|
||||||
|
pointsTotal, streak := 0, 0
|
||||||
|
if cs, err := h.Checkin.Status(user.ID); err == nil && cs != nil {
|
||||||
|
pointsTotal = cs.TotalPoints
|
||||||
|
streak = cs.Streak
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"user": gin.H{
|
"user": gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
@@ -57,6 +64,8 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
|||||||
"stats": gin.H{
|
"stats": gin.H{
|
||||||
"post_count": postCount,
|
"post_count": postCount,
|
||||||
"comment_count": commentCount,
|
"comment_count": commentCount,
|
||||||
|
"points": pointsTotal,
|
||||||
|
"streak": streak,
|
||||||
},
|
},
|
||||||
"posts": posts,
|
"posts": posts,
|
||||||
"posts_total": postsTotal,
|
"posts_total": postsTotal,
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ func (m *AuthMiddleware) parseToken(c *gin.Context) (*service.UserClaims, bool)
|
|||||||
if _, err := m.auth.ValidateClaims(claims); err != nil {
|
if _, err := m.auth.ValidateClaims(claims); err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
// 异步刷新在线心跳(SQL 每 60s 限频一次),不阻塞请求
|
||||||
|
go m.auth.TouchLastSeen(claims.ID)
|
||||||
return claims, true
|
return claims, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +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{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("自动迁移失败: %w", err)
|
return fmt.Errorf("自动迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type User struct {
|
|||||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||||
Banned bool `gorm:"default:false" json:"banned"`
|
Banned bool `gorm:"default:false" json:"banned"`
|
||||||
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
|
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
|
||||||
|
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
@@ -98,11 +99,14 @@ type Post struct {
|
|||||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comment 评论
|
// Comment 评论(主评论 = 楼层,ParentID 为空;子评论挂 root_id 对应楼层下)
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论 ID,NULL = 主评论(楼层)
|
||||||
|
RootID *uint `gorm:"index" json:"root_id"` // 所属楼层(顶层主评论)ID,子评论必填
|
||||||
|
Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1
|
||||||
Content string `gorm:"type:text;not null" json:"content"`
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -132,6 +136,7 @@ type Checkin struct {
|
|||||||
// 通知类型
|
// 通知类型
|
||||||
const (
|
const (
|
||||||
NotificationTypeComment = "comment" // 评论了你的帖子
|
NotificationTypeComment = "comment" // 评论了你的帖子
|
||||||
|
NotificationTypeReply = "reply" // 回复了你的评论
|
||||||
NotificationTypeLike = "like" // 点赞了你的帖子
|
NotificationTypeLike = "like" // 点赞了你的帖子
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -150,3 +155,23 @@ type Notification struct {
|
|||||||
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
|
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
|
||||||
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announcement 站点公告(后台文章管理:支持草稿、标签与标签预设色)
|
||||||
|
type Announcement struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Title string `gorm:"size:200;not null" json:"title"`
|
||||||
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
|
Tag string `gorm:"size:32;not null;default:公告" json:"tag"`
|
||||||
|
TagColor string `gorm:"size:16;not null;default:blue" json:"tag_color"` // blue/green/orange/red/purple/gray
|
||||||
|
Published bool `gorm:"not null;index" json:"published"` // 显式写入 false;不可用 default:true,否则草稿零值会被 GORM 省略而落成已发布
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
|
||||||
|
type SiteSetting struct {
|
||||||
|
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||||
|
Value string `gorm:"size:255;not null;default:''" json:"value"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
notifSvc := service.NewNotificationService(model.DB)
|
notifSvc := service.NewNotificationService(model.DB)
|
||||||
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)
|
||||||
limiter := service.DefaultRateLimiter()
|
limiter := service.DefaultRateLimiter()
|
||||||
|
|
||||||
h := &handler.Handlers{
|
h := &handler.Handlers{
|
||||||
@@ -57,6 +58,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
Notification: notifSvc,
|
Notification: notifSvc,
|
||||||
OverviewSvc: overviewSvc,
|
OverviewSvc: overviewSvc,
|
||||||
Checkin: checkinSvc,
|
Checkin: checkinSvc,
|
||||||
|
Announcement: announcementSvc,
|
||||||
}
|
}
|
||||||
|
|
||||||
authMW := middleware.NewAuthMiddleware(authSvc)
|
authMW := middleware.NewAuthMiddleware(authSvc)
|
||||||
@@ -77,6 +79,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
||||||
pubAPI.GET("/users/:id", h.UserProfile)
|
pubAPI.GET("/users/:id", h.UserProfile)
|
||||||
pubAPI.GET("/users/:id/comments", h.UserComments)
|
pubAPI.GET("/users/:id/comments", h.UserComments)
|
||||||
|
pubAPI.GET("/announcements", h.AnnouncementsList)
|
||||||
|
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
|
||||||
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 防护)
|
||||||
@@ -112,6 +116,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adminAPI.GET("/dashboard", func(c *gin.Context) {
|
adminAPI.GET("/dashboard", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"message": "admin dashboard"})
|
c.JSON(200, gin.H{"message": "admin dashboard"})
|
||||||
})
|
})
|
||||||
|
// 站点公告文章管理
|
||||||
|
adminAPI.GET("/announcements", h.AdminListAnnouncements)
|
||||||
|
adminAPI.POST("/announcements", h.AdminCreateAnnouncement)
|
||||||
|
adminAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
|
||||||
|
adminAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.NoRoute(func(c *gin.Context) {
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
|||||||
149
backend/service/announcement.go
Normal file
149
backend/service/announcement.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 公告标签允许使用的预设色(服务端白名单,禁止透传任意颜色值)
|
||||||
|
var allowedTagColors = map[string]bool{
|
||||||
|
"blue": true, "green": true, "orange": true,
|
||||||
|
"red": true, "purple": true, "gray": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnnouncementService 站点公告服务
|
||||||
|
type AnnouncementService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAnnouncementService(db *gorm.DB) *AnnouncementService {
|
||||||
|
return &AnnouncementService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnnouncementInput 创建/更新公告的入参
|
||||||
|
type AnnouncementInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
TagColor string `json:"tag_color"`
|
||||||
|
Published *bool `json:"published"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize 清洗并校验入参,返回可直接落库的字段
|
||||||
|
func (in *AnnouncementInput) normalize(existing *model.Announcement) error {
|
||||||
|
in.Title = strings.TrimSpace(in.Title)
|
||||||
|
if in.Title == "" {
|
||||||
|
return errors.New("标题不能为空")
|
||||||
|
}
|
||||||
|
if len([]rune(in.Title)) > 200 {
|
||||||
|
return errors.New("标题不能超过 200 字")
|
||||||
|
}
|
||||||
|
in.Content = strings.TrimSpace(in.Content)
|
||||||
|
if in.Content == "" {
|
||||||
|
return errors.New("内容不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
in.Tag = strings.TrimSpace(in.Tag)
|
||||||
|
if in.Tag == "" {
|
||||||
|
in.Tag = "公告"
|
||||||
|
}
|
||||||
|
if len([]rune(in.Tag)) > 32 {
|
||||||
|
return errors.New("标签不能超过 32 字")
|
||||||
|
}
|
||||||
|
|
||||||
|
in.TagColor = strings.ToLower(strings.TrimSpace(in.TagColor))
|
||||||
|
if in.TagColor == "" {
|
||||||
|
if existing != nil && existing.TagColor != "" {
|
||||||
|
in.TagColor = existing.TagColor
|
||||||
|
} else {
|
||||||
|
in.TagColor = "blue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowedTagColors[in.TagColor] {
|
||||||
|
return errors.New("不支持的标签颜色")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTo 把入参写入模型(Published 为指针以区分"未传"与"显式 false")
|
||||||
|
func (in *AnnouncementInput) applyTo(a *model.Announcement) {
|
||||||
|
a.Title = in.Title
|
||||||
|
a.Content = in.Content
|
||||||
|
a.Tag = in.Tag
|
||||||
|
a.TagColor = in.TagColor
|
||||||
|
if in.Published != nil {
|
||||||
|
a.Published = *in.Published
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublished 已发布公告(首页右栏 / 公开列表),按发布时间倒序
|
||||||
|
func (s *AnnouncementService) ListPublished(limit int) ([]model.Announcement, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
var list []model.Announcement
|
||||||
|
err := s.db.Where("published = ?", true).
|
||||||
|
Order("created_at DESC, id DESC").Limit(limit).Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublished 取单条已发布公告(详情页);草稿/不存在返回 gorm.ErrRecordNotFound
|
||||||
|
func (s *AnnouncementService) GetPublished(id uint) (*model.Announcement, error) {
|
||||||
|
var a model.Announcement
|
||||||
|
err := s.db.Where("published = ?", true).First(&a, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll 管理后台:含草稿,按更新时间倒序
|
||||||
|
func (s *AnnouncementService) ListAll() ([]model.Announcement, error) {
|
||||||
|
var list []model.Announcement
|
||||||
|
err := s.db.Order("created_at DESC, id DESC").Limit(100).Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建公告
|
||||||
|
func (s *AnnouncementService) Create(in *AnnouncementInput) (*model.Announcement, error) {
|
||||||
|
if err := in.normalize(nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a := &model.Announcement{Published: true}
|
||||||
|
in.applyTo(a)
|
||||||
|
if err := s.db.Create(a).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新公告
|
||||||
|
func (s *AnnouncementService) Update(id uint, in *AnnouncementInput) (*model.Announcement, error) {
|
||||||
|
var a model.Announcement
|
||||||
|
if err := s.db.First(&a, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := in.normalize(&a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
in.applyTo(&a)
|
||||||
|
if err := s.db.Save(&a).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除公告(软删除)
|
||||||
|
func (s *AnnouncementService) Delete(id uint) error {
|
||||||
|
result := s.db.Delete(&model.Announcement{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -18,27 +18,114 @@ func NewCommentService(db *gorm.DB) *CommentService {
|
|||||||
return &CommentService{db: db}
|
return &CommentService{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByPost 获取帖子的评论列表
|
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
|
||||||
func (s *CommentService) ListByPost(postID uint) ([]model.Comment, error) {
|
type CommentNode struct {
|
||||||
var comments []model.Comment
|
model.Comment
|
||||||
err := s.db.Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished).
|
Replies []CommentNode `json:"replies,omitempty"`
|
||||||
Order("created_at ASC").
|
|
||||||
Preload("User").
|
|
||||||
Find(&comments).Error
|
|
||||||
return comments, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建评论
|
// maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套
|
||||||
func (s *CommentService) Create(userID, postID uint, content string) (*model.Comment, error) {
|
const maxReplyDepth = 10
|
||||||
|
|
||||||
|
// ListFloorPaged 楼层分页获取帖子评论树:
|
||||||
|
// 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。
|
||||||
|
// 返回:树根列表、楼层数、全部评论数(含回复)。
|
||||||
|
func (s *CommentService) ListFloorPaged(postID uint, page, size int) ([]CommentNode, int64, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size < 1 || size > 100 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
base := s.db.Model(&model.Comment{}).
|
||||||
|
Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished)
|
||||||
|
|
||||||
|
var floors, totalComments int64
|
||||||
|
if err := base.Session(&gorm.Session{}).
|
||||||
|
Where("parent_id IS NULL").Count(&floors).Error; err != nil {
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
if err := base.Session(&gorm.Session{}).Count(&totalComments).Error; err != nil {
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 本页楼层(主评论)
|
||||||
|
var roots []model.Comment
|
||||||
|
if err := base.Session(&gorm.Session{}).
|
||||||
|
Where("parent_id IS NULL").
|
||||||
|
Order("created_at ASC").
|
||||||
|
Preload("User").
|
||||||
|
Offset((page - 1) * size).
|
||||||
|
Limit(size).
|
||||||
|
Find(&roots).Error; err != nil {
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
if len(roots) == 0 {
|
||||||
|
return []CommentNode{}, floors, totalComments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 一次拉齐本页全部楼层的所有回复(root_id 冗余,免递归查询)
|
||||||
|
rootIDs := make([]uint, len(roots))
|
||||||
|
for i, r := range roots {
|
||||||
|
rootIDs[i] = r.ID
|
||||||
|
}
|
||||||
|
var replies []model.Comment
|
||||||
|
if err := s.db.Where("root_id IN (?) AND status = ?", rootIDs, model.ContentStatusPublished).
|
||||||
|
Order("created_at ASC").
|
||||||
|
Preload("User").
|
||||||
|
Find(&replies).Error; err != nil {
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 组装树:按 parent_id 建索引递归构造;父缺失(已删/脏数据)时降级挂到所属楼层根部
|
||||||
|
children := make(map[uint][]model.Comment, len(replies)) // parentID -> 直接子评论(时间正序)
|
||||||
|
for _, r := range replies {
|
||||||
|
if r.ParentID != nil {
|
||||||
|
children[*r.ParentID] = append(children[*r.ParentID], r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placed := make(map[uint]bool, len(roots)+len(replies))
|
||||||
|
var build func(pid uint) []CommentNode
|
||||||
|
build = func(pid uint) []CommentNode {
|
||||||
|
out := []CommentNode{}
|
||||||
|
for _, c := range children[pid] {
|
||||||
|
out = append(out, CommentNode{Comment: c, Replies: build(c.ID)})
|
||||||
|
placed[c.ID] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
result := make([]CommentNode, len(roots))
|
||||||
|
for i := range roots {
|
||||||
|
result[i] = CommentNode{Comment: roots[i], Replies: build(roots[i].ID)}
|
||||||
|
placed[roots[i].ID] = true
|
||||||
|
}
|
||||||
|
// 防御性兜底:父评论不在树中的回复 → 挂到所属楼层根部,保证不丢内容
|
||||||
|
for _, r := range replies {
|
||||||
|
if placed[r.ID] || r.RootID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range result {
|
||||||
|
if result[i].ID == *r.RootID {
|
||||||
|
result[i].Replies = append(result[i].Replies, CommentNode{Comment: r, Replies: build(r.ID)})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, floors, totalComments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建评论(parentID 为 nil 时发主评论/楼层,否则发为对应评论的子回复)
|
||||||
|
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)
|
||||||
|
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint) (*model.Comment, *model.Comment, error) {
|
||||||
content = strings.TrimSpace(content)
|
content = strings.TrimSpace(content)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return nil, errors.New("评论内容不能为空")
|
return nil, nil, errors.New("评论内容不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查帖子是否存在且未锁定评论
|
// 检查帖子是否存在且未锁定评论
|
||||||
var post model.Post
|
var post model.Post
|
||||||
if err := s.db.First(&post, postID).Error; err != nil {
|
if err := s.db.First(&post, postID).Error; err != nil {
|
||||||
return nil, errors.New("帖子不存在")
|
return nil, nil, errors.New("帖子不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
comment := &model.Comment{
|
comment := &model.Comment{
|
||||||
@@ -47,14 +134,36 @@ func (s *CommentService) Create(userID, postID uint, content string) (*model.Com
|
|||||||
Content: content,
|
Content: content,
|
||||||
Status: model.ContentStatusPublished,
|
Status: model.ContentStatusPublished,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 子回复:校验父评论(同帖、已发布),继承楼层根与层级
|
||||||
|
var parent *model.Comment
|
||||||
|
if parentID != nil {
|
||||||
|
parent = &model.Comment{}
|
||||||
|
if err := s.db.
|
||||||
|
Where("id = ? AND post_id = ? AND status = ?", *parentID, postID, model.ContentStatusPublished).
|
||||||
|
First(parent).Error; err != nil {
|
||||||
|
return nil, nil, errors.New("回复的评论不存在")
|
||||||
|
}
|
||||||
|
if parent.Depth >= maxReplyDepth {
|
||||||
|
return nil, nil, errors.New("回复层级过深")
|
||||||
|
}
|
||||||
|
rootID := parent.ID
|
||||||
|
if parent.RootID != nil {
|
||||||
|
rootID = *parent.RootID
|
||||||
|
}
|
||||||
|
comment.ParentID = parentID
|
||||||
|
comment.RootID = &rootID
|
||||||
|
comment.Depth = parent.Depth + 1
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.db.Create(comment).Error; err != nil {
|
if err := s.db.Create(comment).Error; err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
// 更新帖子评论数
|
// 更新帖子评论数
|
||||||
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
||||||
// 预加载用户
|
// 预加载用户
|
||||||
s.db.Preload("User").First(comment, comment.ID)
|
s.db.Preload("User").First(comment, comment.ID)
|
||||||
return comment, nil
|
return comment, parent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserCommentItem 用户评论列表项(含帖子标题便于跳转)
|
// UserCommentItem 用户评论列表项(含帖子标题便于跳转)
|
||||||
@@ -101,7 +210,7 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) {
|
|||||||
return total, err
|
return total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除评论(仅作者或管理员可操作)
|
// Delete 删除评论(仅作者或管理员可操作),级联软删整棵子树
|
||||||
func (s *CommentService) Delete(commentID, userID uint, role string) error {
|
func (s *CommentService) Delete(commentID, userID uint, role string) error {
|
||||||
var comment model.Comment
|
var comment model.Comment
|
||||||
if err := s.db.First(&comment, commentID).Error; err != nil {
|
if err := s.db.First(&comment, commentID).Error; err != nil {
|
||||||
@@ -111,12 +220,38 @@ func (s *CommentService) Delete(commentID, userID uint, role string) error {
|
|||||||
if comment.UserID != userID && role != RoleAdmin {
|
if comment.UserID != userID && role != RoleAdmin {
|
||||||
return errors.New("无权限删除此评论")
|
return errors.New("无权限删除此评论")
|
||||||
}
|
}
|
||||||
// 软删除
|
// 收集子树:取同楼层全部评论,多轮标记出以目标为祖先的集合(含自身,深度有限必然收敛)
|
||||||
if err := s.db.Delete(&comment).Error; err != nil {
|
var all []model.Comment
|
||||||
|
if comment.RootID != nil {
|
||||||
|
s.db.Where("root_id = ?", *comment.RootID).Find(&all)
|
||||||
|
} else {
|
||||||
|
s.db.Where("root_id = ?", comment.ID).Find(&all)
|
||||||
|
}
|
||||||
|
descendants := map[uint]bool{comment.ID: true}
|
||||||
|
for i := 0; i <= maxReplyDepth; i++ {
|
||||||
|
added := false
|
||||||
|
for _, c := range all {
|
||||||
|
if c.ParentID == nil || descendants[c.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if descendants[*c.ParentID] {
|
||||||
|
descendants[c.ID] = true
|
||||||
|
added = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !added {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(descendants))
|
||||||
|
for id := range descendants {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
// 软删子树并按实际条数递减帖子评论数
|
||||||
|
if err := s.db.Delete(&model.Comment{}, ids).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 更新帖子评论数
|
|
||||||
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
|
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
|
||||||
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)"))
|
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", len(ids)))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -17,21 +17,26 @@ func NewOverviewService(db *gorm.DB) *OverviewService {
|
|||||||
return &OverviewService{db: db}
|
return &OverviewService{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OverviewStats 社区整体统计
|
// OverviewStats 社区整体统计(社区脉搏卡)
|
||||||
type OverviewStats struct {
|
type OverviewStats struct {
|
||||||
Posts int64 `json:"posts"`
|
Posts int64 `json:"posts"`
|
||||||
Users int64 `json:"users"`
|
Users int64 `json:"users"`
|
||||||
Comments int64 `json:"comments"`
|
Comments int64 `json:"comments"`
|
||||||
TodayPosts int64 `json:"today_posts"`
|
TodayPosts int64 `json:"today_posts"`
|
||||||
|
TodayUsers int64 `json:"today_users"`
|
||||||
|
TodayComments int64 `json:"today_comments"`
|
||||||
|
Online int64 `json:"online"` // 近 5 分钟活跃用户
|
||||||
|
PeakOnline int64 `json:"peak_online"` // 历史在线峰值
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveUser 近 14 天活跃用户
|
// ActiveUser 本周活跃用户(发帖数 / 回复数分列)
|
||||||
type ActiveUser struct {
|
type ActiveUser struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
ActivityCount int64 `json:"activity_count"`
|
PostCount int64 `json:"post_count"`
|
||||||
|
CommentCount int64 `json:"comment_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BoardCount 板块及其已发布帖子计数(Board 字段被 encoding/json 展平)
|
// BoardCount 板块及其已发布帖子计数(Board 字段被 encoding/json 展平)
|
||||||
@@ -40,21 +45,13 @@ type BoardCount struct {
|
|||||||
PostCount int64 `json:"post_count"`
|
PostCount int64 `json:"post_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestCommentItem 首页"最新评论"条目(纯文本摘要 + 所属帖子)
|
// AnnouncementItem 首页"站点公告"条目(标签/日期/标题)
|
||||||
type LatestCommentItem struct {
|
type AnnouncementItem struct {
|
||||||
ID uint `json:"id"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
User struct {
|
|
||||||
ID uint `json:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Avatar string `json:"avatar"`
|
|
||||||
} `json:"user"`
|
|
||||||
Post struct {
|
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
} `json:"post"`
|
Tag string `json:"tag"`
|
||||||
|
TagColor string `json:"tag_color"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserItem 最新注册成员
|
// NewUserItem 最新注册成员
|
||||||
@@ -72,22 +69,27 @@ type OverviewData struct {
|
|||||||
Hot []PostListItem `json:"hot"`
|
Hot []PostListItem `json:"hot"`
|
||||||
ActiveUsers []ActiveUser `json:"active_users"`
|
ActiveUsers []ActiveUser `json:"active_users"`
|
||||||
Boards []BoardCount `json:"boards"`
|
Boards []BoardCount `json:"boards"`
|
||||||
LatestComments []LatestCommentItem `json:"latest_comments"`
|
Announcements []AnnouncementItem `json:"announcements"`
|
||||||
NewUsers []NewUserItem `json:"new_users"`
|
NewUsers []NewUserItem `json:"new_users"`
|
||||||
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncateText 折叠空白并按 rune 截断(评论为纯文本,无 HTML 注入面)
|
const (
|
||||||
func truncateText(s string, n int) string {
|
// onlineWindow 判定"在线"的最近活跃窗口
|
||||||
s = strings.Join(strings.Fields(s), " ")
|
onlineWindow = 5 * time.Minute
|
||||||
r := []rune(s)
|
// peakOnlineKey 历史在线峰值在 site_settings 中的键
|
||||||
if len(r) > n {
|
peakOnlineKey = "peak_online"
|
||||||
return string(r[:n]) + "…"
|
)
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeUserWindow = 14 * 24 * time.Hour
|
// startOfWeek 返回本周一 00:00(本地时区)
|
||||||
|
func startOfWeek(t time.Time) time.Time {
|
||||||
|
wd := int(t.Weekday())
|
||||||
|
if wd == 0 {
|
||||||
|
wd = 7 // 周日归到本周末尾
|
||||||
|
}
|
||||||
|
d := t.AddDate(0, 0, -(wd - 1))
|
||||||
|
return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
// Get 聚合首页数据。hot 的 liked 态由 handler 层按当前用户填充。
|
// Get 聚合首页数据。hot 的 liked 态由 handler 层按当前用户填充。
|
||||||
func (s *OverviewService) Get() (*OverviewData, error) {
|
func (s *OverviewService) Get() (*OverviewData, error) {
|
||||||
@@ -95,13 +97,14 @@ func (s *OverviewService) Get() (*OverviewData, error) {
|
|||||||
Hot: []PostListItem{},
|
Hot: []PostListItem{},
|
||||||
ActiveUsers: []ActiveUser{},
|
ActiveUsers: []ActiveUser{},
|
||||||
Boards: []BoardCount{},
|
Boards: []BoardCount{},
|
||||||
LatestComments: []LatestCommentItem{},
|
Announcements: []AnnouncementItem{},
|
||||||
NewUsers: []NewUserItem{},
|
NewUsers: []NewUserItem{},
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
since := now.Add(-activeUserWindow)
|
weekStart := startOfWeek(now)
|
||||||
|
onlineSince := now.Add(-onlineWindow)
|
||||||
|
|
||||||
// 统计
|
// 统计
|
||||||
postQ := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished)
|
postQ := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished)
|
||||||
@@ -119,6 +122,24 @@ func (s *OverviewService) Get() (*OverviewData, error) {
|
|||||||
Count(&data.Stats.TodayPosts).Error; err != nil {
|
Count(&data.Stats.TodayPosts).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.db.Model(&model.User{}).
|
||||||
|
Where("created_at >= ?", dayStart).
|
||||||
|
Count(&data.Stats.TodayUsers).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&model.Comment{}).
|
||||||
|
Where("status = ? AND created_at >= ?", model.ContentStatusPublished, dayStart).
|
||||||
|
Count(&data.Stats.TodayComments).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在线用户(近 5 分钟有请求)与历史峰值
|
||||||
|
if err := s.db.Model(&model.User{}).
|
||||||
|
Where("last_seen_at IS NOT NULL AND last_seen_at >= ? AND banned = ?", onlineSince, false).
|
||||||
|
Count(&data.Stats.Online).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data.Stats.PeakOnline = s.refreshPeakOnline(data.Stats.Online)
|
||||||
|
|
||||||
// 热门榜:非置顶,按 赞 + 评论×2
|
// 热门榜:非置顶,按 赞 + 评论×2
|
||||||
var hotPosts []model.Post
|
var hotPosts []model.Post
|
||||||
@@ -129,27 +150,30 @@ func (s *OverviewService) Get() (*OverviewData, error) {
|
|||||||
}
|
}
|
||||||
data.Hot = toPostListItems(hotPosts)
|
data.Hot = toPostListItems(hotPosts)
|
||||||
|
|
||||||
// 活跃用户:近 14 天发帖+评论合并计数,JOIN 用户过滤封禁/软删
|
// 本周活跃用户:发帖/评论分别计数,JOIN 用户过滤封禁/软删
|
||||||
type activityRow struct {
|
type activityRow struct {
|
||||||
UserID uint
|
UserID uint
|
||||||
ActivityCount int64
|
PostCount int64
|
||||||
|
CommentCount int64
|
||||||
}
|
}
|
||||||
var rows []activityRow
|
var rows []activityRow
|
||||||
activitySQL := `
|
activitySQL := `
|
||||||
SELECT a.user_id AS user_id, COUNT(*) AS activity_count
|
SELECT a.user_id AS user_id,
|
||||||
|
SUM(a.is_post) AS post_count,
|
||||||
|
SUM(1 - a.is_post) AS comment_count
|
||||||
FROM (
|
FROM (
|
||||||
SELECT user_id FROM posts WHERE status = ? AND created_at >= ? AND deleted_at IS NULL
|
SELECT user_id, 1 AS is_post FROM posts WHERE status = ? AND created_at >= ? AND deleted_at IS NULL
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT user_id FROM comments WHERE status = ? AND created_at >= ? AND deleted_at IS NULL
|
SELECT user_id, 0 AS is_post FROM comments WHERE status = ? AND created_at >= ? AND deleted_at IS NULL
|
||||||
) AS a
|
) AS a
|
||||||
JOIN users AS u ON u.id = a.user_id
|
JOIN users AS u ON u.id = a.user_id
|
||||||
WHERE u.banned = ? AND u.deleted_at IS NULL
|
WHERE u.banned = ? AND u.deleted_at IS NULL
|
||||||
GROUP BY a.user_id
|
GROUP BY a.user_id
|
||||||
ORDER BY activity_count DESC, a.user_id ASC
|
ORDER BY COUNT(*) DESC, a.user_id ASC
|
||||||
LIMIT 5`
|
LIMIT 5`
|
||||||
if err := s.db.Raw(activitySQL,
|
if err := s.db.Raw(activitySQL,
|
||||||
model.ContentStatusPublished, since,
|
model.ContentStatusPublished, weekStart,
|
||||||
model.ContentStatusPublished, since,
|
model.ContentStatusPublished, weekStart,
|
||||||
false).Scan(&rows).Error; err != nil {
|
false).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -176,7 +200,8 @@ LIMIT 5`
|
|||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
Nickname: u.Nickname,
|
Nickname: u.Nickname,
|
||||||
Avatar: u.Avatar,
|
Avatar: u.Avatar,
|
||||||
ActivityCount: r.ActivityCount,
|
PostCount: r.PostCount,
|
||||||
|
CommentCount: r.CommentCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,47 +230,20 @@ LIMIT 5`
|
|||||||
data.Boards = append(data.Boards, BoardCount{Board: b, PostCount: countMap[b.ID]})
|
data.Boards = append(data.Boards, BoardCount{Board: b, PostCount: countMap[b.ID]})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最新评论:JOIN 未封禁用户与已发布帖,一条 SQL 取齐
|
// 站点公告(已发布,最新 5 条)
|
||||||
type latestCommentRow struct {
|
var anns []model.Announcement
|
||||||
ID uint
|
if err := s.db.Where("published = ?", true).
|
||||||
Content string
|
Order("created_at DESC, id DESC").Limit(5).Find(&anns).Error; err != nil {
|
||||||
CreatedAt time.Time
|
|
||||||
UID uint
|
|
||||||
Username string
|
|
||||||
Nickname string
|
|
||||||
Avatar string
|
|
||||||
PID uint
|
|
||||||
Title string
|
|
||||||
}
|
|
||||||
var commentRows []latestCommentRow
|
|
||||||
commentSQL := `
|
|
||||||
SELECT c.id, c.content, c.created_at,
|
|
||||||
u.id AS uid, u.username AS username, u.nickname AS nickname, u.avatar AS avatar,
|
|
||||||
p.id AS pid, p.title AS title
|
|
||||||
FROM comments c
|
|
||||||
JOIN users u ON u.id = c.user_id AND u.banned = ? AND u.deleted_at IS NULL
|
|
||||||
JOIN posts p ON p.id = c.post_id AND p.status = ? AND p.deleted_at IS NULL
|
|
||||||
WHERE c.status = ? AND c.deleted_at IS NULL
|
|
||||||
ORDER BY c.created_at DESC
|
|
||||||
LIMIT 6`
|
|
||||||
if err := s.db.Raw(commentSQL,
|
|
||||||
false, model.ContentStatusPublished, model.ContentStatusPublished).
|
|
||||||
Scan(&commentRows).Error; err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, r := range commentRows {
|
for _, a := range anns {
|
||||||
item := LatestCommentItem{
|
data.Announcements = append(data.Announcements, AnnouncementItem{
|
||||||
ID: r.ID,
|
ID: a.ID,
|
||||||
Content: truncateText(r.Content, 60),
|
Title: a.Title,
|
||||||
CreatedAt: r.CreatedAt,
|
Tag: a.Tag,
|
||||||
}
|
TagColor: a.TagColor,
|
||||||
item.User.ID = r.UID
|
CreatedAt: a.CreatedAt,
|
||||||
item.User.Username = r.Username
|
})
|
||||||
item.User.Nickname = r.Nickname
|
|
||||||
item.User.Avatar = r.Avatar
|
|
||||||
item.Post.ID = r.PID
|
|
||||||
item.Post.Title = r.Title
|
|
||||||
data.LatestComments = append(data.LatestComments, item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最新注册成员
|
// 最新注册成员
|
||||||
@@ -267,3 +265,26 @@ LIMIT 6`
|
|||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshPeakOnline 读取历史峰值,当前在线更高则落库刷新
|
||||||
|
func (s *OverviewService) refreshPeakOnline(online int64) int64 {
|
||||||
|
var setting model.SiteSetting
|
||||||
|
err := s.db.Where("key = ?", peakOnlineKey).First(&setting).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
setting = model.SiteSetting{Key: peakOnlineKey, Value: strconv.FormatInt(online, 10)}
|
||||||
|
if online > 0 {
|
||||||
|
s.db.Create(&setting)
|
||||||
|
}
|
||||||
|
return online
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
peak, _ := strconv.ParseInt(setting.Value, 10, 64)
|
||||||
|
if online > peak {
|
||||||
|
s.db.Model(&model.SiteSetting{}).Where("key = ?", peakOnlineKey).
|
||||||
|
Update("value", strconv.FormatInt(online, 10))
|
||||||
|
return online
|
||||||
|
}
|
||||||
|
return peak
|
||||||
|
}
|
||||||
|
|||||||
15
backend/service/presence.go
Normal file
15
backend/service/presence.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// TouchLastSeen 更新用户最近活跃时间(在线统计)。
|
||||||
|
// SQL 条件限频:每用户每 60 秒最多真正写一次,其余为命中 0 行的空更新;
|
||||||
|
// 调用方以 goroutine 异步触发,不增加请求链路延迟。
|
||||||
|
func (s *AuthService) TouchLastSeen(userID uint) {
|
||||||
|
threshold := time.Now().Add(-time.Minute)
|
||||||
|
_ = s.db.Exec(
|
||||||
|
`UPDATE users SET last_seen_at = NOW()
|
||||||
|
WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)`,
|
||||||
|
userID, threshold,
|
||||||
|
).Error
|
||||||
|
}
|
||||||
372
frontend/app/admin/announcements/AnnouncementAdmin.tsx
Normal file
372
frontend/app/admin/announcements/AnnouncementAdmin.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
ExternalLink,
|
||||||
|
Megaphone,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
apiAdminListAnnouncements,
|
||||||
|
apiAdminCreateAnnouncement,
|
||||||
|
apiAdminUpdateAnnouncement,
|
||||||
|
apiAdminDeleteAnnouncement,
|
||||||
|
type Announcement,
|
||||||
|
type AnnouncementColor,
|
||||||
|
type AnnouncementInput,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
const COLORS: { value: AnnouncementColor; label: string }[] = [
|
||||||
|
{ value: "blue", label: "蓝色" },
|
||||||
|
{ value: "green", label: "绿色" },
|
||||||
|
{ value: "orange", label: "橙色" },
|
||||||
|
{ value: "red", label: "红色" },
|
||||||
|
{ value: "purple", label: "紫色" },
|
||||||
|
{ value: "gray", label: "灰色" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type FormState = AnnouncementInput & { id: number | null };
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormState = {
|
||||||
|
id: null,
|
||||||
|
title: "",
|
||||||
|
content: "",
|
||||||
|
tag: "公告",
|
||||||
|
tag_color: "blue",
|
||||||
|
published: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AnnouncementAdmin() {
|
||||||
|
const [list, setList] = useState<Announcement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState("");
|
||||||
|
const [form, setForm] = useState<FormState | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAdminListAnnouncements();
|
||||||
|
setList(res.announcements);
|
||||||
|
setLoadError("");
|
||||||
|
} catch (e) {
|
||||||
|
setLoadError(e instanceof Error ? e.message : "加载公告失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const startCreate = () => setForm({ ...EMPTY_FORM });
|
||||||
|
const startEdit = (a: Announcement) =>
|
||||||
|
setForm({
|
||||||
|
id: a.id,
|
||||||
|
title: a.title,
|
||||||
|
content: a.content,
|
||||||
|
tag: a.tag,
|
||||||
|
tag_color: a.tag_color,
|
||||||
|
published: a.published,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form) return;
|
||||||
|
const payload: AnnouncementInput = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
content: form.content.trim(),
|
||||||
|
tag: form.tag.trim() || "公告",
|
||||||
|
tag_color: form.tag_color,
|
||||||
|
published: form.published,
|
||||||
|
};
|
||||||
|
if (!payload.title || !payload.content) {
|
||||||
|
toast("标题和内容不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (form.id == null) {
|
||||||
|
await apiAdminCreateAnnouncement(payload);
|
||||||
|
toast("公告已发布", "ok");
|
||||||
|
} else {
|
||||||
|
await apiAdminUpdateAnnouncement(form.id, payload);
|
||||||
|
toast("公告已更新", "ok");
|
||||||
|
}
|
||||||
|
setForm(null);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(a: Announcement) {
|
||||||
|
if (!window.confirm(`确定删除公告「${a.title}」吗?此操作不可撤销。`)) return;
|
||||||
|
setDeletingId(a.id);
|
||||||
|
try {
|
||||||
|
await apiAdminDeleteAnnouncement(a.id);
|
||||||
|
setList((l) => l.filter((x) => x.id !== a.id));
|
||||||
|
if (form?.id === a.id) setForm(null);
|
||||||
|
toast("公告已删除", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "删除失败");
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 页头 */}
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap mb-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5" style={{ color: "var(--ink)" }}>
|
||||||
|
<span
|
||||||
|
className="w-9 h-9 rounded-xl flex items-center justify-center"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<Megaphone size={18} />
|
||||||
|
</span>
|
||||||
|
站点公告管理
|
||||||
|
</h1>
|
||||||
|
<p className="meta mt-1.5 text-[12.5px]">发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色</p>
|
||||||
|
</div>
|
||||||
|
{form?.id == null && (
|
||||||
|
<button onClick={startCreate} className="btn btn-primary">
|
||||||
|
<Plus size={15} /> 新建公告
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[400px_minmax(0,1fr)] gap-6 items-start">
|
||||||
|
{/* 编辑/新建表单 */}
|
||||||
|
{form ? (
|
||||||
|
<div className="panel p-5 lg:sticky lg:top-24">
|
||||||
|
<p className="font-extrabold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
|
||||||
|
{form.id == null ? "新建公告" : "编辑公告"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="field-label" htmlFor="anno-title">
|
||||||
|
标题
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="anno-title"
|
||||||
|
className="field mb-4"
|
||||||
|
maxLength={200}
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||||
|
placeholder="请输入公告标题"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-4 mb-4 flex-wrap">
|
||||||
|
<div className="w-36">
|
||||||
|
<label className="field-label" htmlFor="anno-tag">
|
||||||
|
标签
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="anno-tag"
|
||||||
|
className="field"
|
||||||
|
maxLength={32}
|
||||||
|
value={form.tag}
|
||||||
|
onChange={(e) => setForm({ ...form, tag: e.target.value })}
|
||||||
|
placeholder="公告"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="field-label">标签颜色</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.value}
|
||||||
|
type="button"
|
||||||
|
title={c.label}
|
||||||
|
aria-label={c.label}
|
||||||
|
aria-pressed={form.tag_color === c.value}
|
||||||
|
className={`j13-anno-dot is-${c.value}`}
|
||||||
|
onClick={() => setForm({ ...form, tag_color: c.value })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="field-label">发布状态</p>
|
||||||
|
<div className="flex items-center gap-2 mb-4" role="group" aria-label="发布状态">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="j13-tab"
|
||||||
|
aria-pressed={form.published}
|
||||||
|
onClick={() => setForm({ ...form, published: true })}
|
||||||
|
style={form.published ? { background: "var(--accent-soft)", borderColor: "color-mix(in srgb, var(--accent) 38%, var(--line))", color: "var(--accent)" } : undefined}
|
||||||
|
>
|
||||||
|
立即发布
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="j13-tab"
|
||||||
|
aria-pressed={!form.published}
|
||||||
|
onClick={() => setForm({ ...form, published: false })}
|
||||||
|
style={!form.published ? { background: "var(--gold-soft)", borderColor: "color-mix(in srgb, var(--gold) 38%, var(--line))", color: "var(--gold)" } : undefined}
|
||||||
|
>
|
||||||
|
存为草稿
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field-label" htmlFor="anno-content">
|
||||||
|
正文(支持 Markdown)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="anno-content"
|
||||||
|
className="field min-h-[260px]"
|
||||||
|
value={form.content}
|
||||||
|
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||||
|
placeholder="请输入公告正文,支持 Markdown 语法…"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 mt-4">
|
||||||
|
<button onClick={save} disabled={saving} className="btn btn-primary flex-1 justify-center">
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={15} className="animate-spin" /> 保存中…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={15} /> {form.id == null ? "发布公告" : "保存修改"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setForm(null)}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn btn-line"
|
||||||
|
aria-label="取消编辑"
|
||||||
|
>
|
||||||
|
<X size={15} /> 取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel p-6 text-center lg:sticky lg:top-24">
|
||||||
|
<span
|
||||||
|
className="inline-flex w-12 h-12 rounded-2xl items-center justify-center mb-3"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<FileText size={22} />
|
||||||
|
</span>
|
||||||
|
<p className="font-bold text-[14.5px]" style={{ color: "var(--ink)" }}>
|
||||||
|
公告编辑器
|
||||||
|
</p>
|
||||||
|
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
|
||||||
|
点击右上「新建公告」发布公告,
|
||||||
|
<br />
|
||||||
|
或在右侧列表中选择已有公告进行编辑。
|
||||||
|
</p>
|
||||||
|
<button onClick={startCreate} className="btn btn-primary mt-4">
|
||||||
|
<Plus size={15} /> 新建公告
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 公告列表 */}
|
||||||
|
<div className="panel overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="px-4 sm:px-5 py-3.5 flex items-center justify-between"
|
||||||
|
style={{ borderBottom: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||||
|
全部公告
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[12px] tabular-nums">共 {list.length} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
|
||||||
|
<Loader2 size={16} className="animate-spin" /> 加载中…
|
||||||
|
</div>
|
||||||
|
) : loadError ? (
|
||||||
|
<p className="alert-error m-4">{loadError}</p>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<p className="meta text-center py-16 text-[13px]">还没有公告,点击「新建公告」发布第一条吧</p>
|
||||||
|
) : (
|
||||||
|
list.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className="group flex items-center gap-3 px-4 sm:px-5 py-3.5"
|
||||||
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||||
|
{!a.published && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] font-bold leading-[1.55]"
|
||||||
|
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||||
|
>
|
||||||
|
草稿
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="meta text-[11px] tabular-nums">{formatDate(a.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="mt-1 truncate text-[14px] font-semibold"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
title={a.title}
|
||||||
|
>
|
||||||
|
{a.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{a.published && (
|
||||||
|
<Link
|
||||||
|
href={`/announcement/${a.id}`}
|
||||||
|
target="_blank"
|
||||||
|
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
title="查看"
|
||||||
|
aria-label="查看公告"
|
||||||
|
>
|
||||||
|
<ExternalLink size={15} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(a)}
|
||||||
|
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
title="编辑"
|
||||||
|
aria-label="编辑公告"
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => remove(a)}
|
||||||
|
disabled={deletingId === a.id}
|
||||||
|
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)] disabled:opacity-50"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--danger)")}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--ink-3)")}
|
||||||
|
title="删除"
|
||||||
|
aria-label="删除公告"
|
||||||
|
>
|
||||||
|
{deletingId === a.id ? <Loader2 size={15} className="animate-spin" /> : <Trash2 size={15} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/app/admin/announcements/page.tsx
Normal file
41
frontend/app/admin/announcements/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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 } from "@/lib/serverData";
|
||||||
|
import AnnouncementAdmin from "./AnnouncementAdmin";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "站点公告管理",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 权限以 Go 端 RequireAdmin 为唯一判定;这里仅决定管理界面是否直出,
|
||||||
|
// 非管理员访问时 SSR 直接给出无权限提示(所有写接口仍由后端 403 兜底)。
|
||||||
|
export default async function AdminAnnouncementsPage() {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AnnouncementAdmin />;
|
||||||
|
}
|
||||||
99
frontend/app/announcement/[id]/page.tsx
Normal file
99
frontend/app/announcement/[id]/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { ChevronRight, Megaphone } from "lucide-react";
|
||||||
|
import { fetchAnnouncementDetail } from "@/lib/api";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await fetchAnnouncementDetail(id).catch(() => null);
|
||||||
|
if (!data) return { title: "公告不存在" };
|
||||||
|
return {
|
||||||
|
title: data.announcement.title,
|
||||||
|
description: `站点公告 · ${data.announcement.tag}`,
|
||||||
|
openGraph: {
|
||||||
|
title: data.announcement.title,
|
||||||
|
description: `站点公告 · ${data.announcement.tag}`,
|
||||||
|
type: "article",
|
||||||
|
publishedTime: data.announcement.created_at,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AnnouncementPage({ params }: PageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await fetchAnnouncementDetail(id).catch(() => null);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const a = data.announcement;
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Article",
|
||||||
|
headline: a.title,
|
||||||
|
datePublished: a.created_at,
|
||||||
|
dateModified: a.updated_at,
|
||||||
|
articleSection: "站点公告",
|
||||||
|
keywords: a.tag,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 面包屑 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<article className="panel p-6 sm:p-8">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||||
|
<time className="meta text-[12px] tabular-nums" dateTime={a.created_at}>
|
||||||
|
{formatDate(a.created_at)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
className="mt-3.5 text-[24px] sm:text-[28px] font-extrabold leading-snug tracking-tight"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{a.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mt-5 pt-5"
|
||||||
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<div className="prose-content">
|
||||||
|
<ReactMarkdown>{a.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
|
||||||
|
<Megaphone size={14} style={{ color: "var(--accent)" }} />
|
||||||
|
<span>由站点管理团队发布</span>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="ml-auto font-semibold transition-colors hover:text-[var(--accent)]"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -378,6 +378,80 @@ h1, h2, h3, h4 {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 帖子列表中的板块小标签:全站统一浅灰配色,避免每行多色眩目 */
|
||||||
|
.j13-board-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.65;
|
||||||
|
background: var(--tint);
|
||||||
|
color: var(--ink-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.j13-board-tag:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 站点公告标签:预设色板(浅/暗成对,文字与底色均满足 AA 对比度) */
|
||||||
|
.j13-anno-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 132px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.j13-anno-tag.is-blue { background: rgba(52, 81, 200, 0.12); color: #3451c8; }
|
||||||
|
.j13-anno-tag.is-green { background: rgba(19, 115, 51, 0.12); color: #137333; }
|
||||||
|
.j13-anno-tag.is-orange { background: rgba(165, 66, 10, 0.12); color: #a5420a; }
|
||||||
|
.j13-anno-tag.is-red { background: rgba(200, 40, 30, 0.1); color: #c8281e; }
|
||||||
|
.j13-anno-tag.is-purple { background: rgba(109, 71, 192, 0.12); color: #6d47c0; }
|
||||||
|
.j13-anno-tag.is-gray { background: rgba(82, 96, 122, 0.13); color: #52607a; }
|
||||||
|
.dark .j13-anno-tag.is-blue { background: rgba(169, 184, 255, 0.18); color: #a9b8ff; }
|
||||||
|
.dark .j13-anno-tag.is-green { background: rgba(88, 209, 138, 0.16); color: #58d18a; }
|
||||||
|
.dark .j13-anno-tag.is-orange { background: rgba(240, 135, 90, 0.17); color: #f0875a; }
|
||||||
|
.dark .j13-anno-tag.is-red { background: rgba(255, 107, 96, 0.16); color: #ff6b60; }
|
||||||
|
.dark .j13-anno-tag.is-purple { background: rgba(192, 168, 245, 0.18); color: #c0a8f5; }
|
||||||
|
.dark .j13-anno-tag.is-gray { background: rgba(170, 182, 207, 0.17); color: #aab6cf; }
|
||||||
|
|
||||||
|
/* 后台颜色选择器圆点 */
|
||||||
|
.j13-anno-dot {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.j13-anno-dot:hover { transform: scale(1.12); }
|
||||||
|
.j13-anno-dot[aria-pressed="true"] {
|
||||||
|
border-color: var(--panel);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
.j13-anno-dot.is-blue { background: #3451c8; }
|
||||||
|
.j13-anno-dot.is-green { background: #137333; }
|
||||||
|
.j13-anno-dot.is-orange { background: #a5420a; }
|
||||||
|
.j13-anno-dot.is-red { background: #c8281e; }
|
||||||
|
.j13-anno-dot.is-purple { background: #6d47c0; }
|
||||||
|
.j13-anno-dot.is-gray { background: #52607a; }
|
||||||
|
.dark .j13-anno-dot.is-blue { background: #a9b8ff; }
|
||||||
|
.dark .j13-anno-dot.is-green { background: #58d18a; }
|
||||||
|
.dark .j13-anno-dot.is-orange { background: #f0875a; }
|
||||||
|
.dark .j13-anno-dot.is-red { background: #ff6b60; }
|
||||||
|
.dark .j13-anno-dot.is-purple { background: #c0a8f5; }
|
||||||
|
.dark .j13-anno-dot.is-gray { background: #aab6cf; }
|
||||||
|
|
||||||
/* ----- 分页 ----- */
|
/* ----- 分页 ----- */
|
||||||
.pager-btn {
|
.pager-btn {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -688,6 +762,147 @@ h1, h2, h3, h4 {
|
|||||||
.prose-content hr { border: none; border-top: 1px solid var(--line); margin: 2.2rem 0; }
|
.prose-content hr { border: none; border-top: 1px solid var(--line); margin: 2.2rem 0; }
|
||||||
.prose-content strong { font-weight: 700; }
|
.prose-content strong { font-weight: 700; }
|
||||||
|
|
||||||
|
/* 评论区的 Markdown 紧凑排版:继承 prose-content 全部规则,仅收紧行距与间距 */
|
||||||
|
.prose-comment { font-size: 14.5px; line-height: 1.7; }
|
||||||
|
.prose-comment p { margin: 0.35rem 0; }
|
||||||
|
.prose-comment blockquote { margin: 0.5rem 0; padding: 0.45rem 0.9rem; }
|
||||||
|
.prose-comment pre { margin: 0.6rem 0; }
|
||||||
|
.prose-comment h1, .prose-comment h2, .prose-comment h3, .prose-comment h4 {
|
||||||
|
font-size: 1em;
|
||||||
|
margin: 0.6rem 0 0.3rem;
|
||||||
|
border-bottom: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端浮动操作条内的点赞按钮:放大到 44px 触控标准 */
|
||||||
|
.j13-bar-like .j13-like {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评论楼层树:抖音风格
|
||||||
|
主评论全宽;子回复折叠在 panel-2 背景容器内,点击"展开 X 条回复"显示
|
||||||
|
子回复之间用淡分隔线;depth≥2 时昵称后显示 ▶ 被回复者 */
|
||||||
|
|
||||||
|
/* 子回复行:紧凑布局,头像 28px */
|
||||||
|
.cmt-reply-row {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.cmt-reply-row:first-child {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子回复昵称 */
|
||||||
|
.cmt-reply-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 楼层号标签(主评论昵称旁) */
|
||||||
|
.cmt-floor-label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-3);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--tint);
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.cmt-floor-label:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 回复文字按钮:放在时间右侧,抖音风格 */
|
||||||
|
.cmt-reply-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-3);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cmt-reply-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右三角箭头(回复子评论时) */
|
||||||
|
.cmt-reply-arrow {
|
||||||
|
color: var(--ink-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 被回复者名称(可点击跳转) */
|
||||||
|
.cmt-reply-target {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-2);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cmt-reply-target:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子回复容器:背景与主评论区分,圆角 */
|
||||||
|
.cmt-replies-wrap {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 孙评论及更深:小幅缩进 + 细引导线 */
|
||||||
|
.cmt-children {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开/收起按钮 */
|
||||||
|
.cmt-fold-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.cmt-fold-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评论内联回复表单:出现在目标评论正下方 */
|
||||||
|
.cmt-reply-form {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.cmt-reply-form .field {
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cmt-replies-wrap {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.cmt-children {
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import "./globals.css";
|
|||||||
import Header from "@/components/Header";
|
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, Board } from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached, getBoardsCached } from "@/lib/serverData";
|
||||||
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
|
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
|
||||||
|
|
||||||
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
||||||
@@ -69,6 +69,9 @@ export default async function RootLayout({
|
|||||||
unread = me.unread_count;
|
unread = me.unread_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全站板块导航(Header 桌面下拉 + 移动抽屉共用)
|
||||||
|
const boards: Board[] = await getBoardsCached();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="zh-CN"
|
lang="zh-CN"
|
||||||
@@ -78,7 +81,7 @@ export default async function RootLayout({
|
|||||||
<a href="#main" className="skip-link">
|
<a href="#main" className="skip-link">
|
||||||
跳到主要内容
|
跳到主要内容
|
||||||
</a>
|
</a>
|
||||||
<Header initialUser={user} initialUnread={unread} initialTheme={theme} />
|
<Header initialUser={user} initialUnread={unread} initialTheme={theme} boards={boards} />
|
||||||
<main
|
<main
|
||||||
id="main"
|
id="main"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
|||||||
@@ -57,12 +57,14 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||||
<div className="mb-7 text-center">
|
<div className="mb-7 text-center">
|
||||||
<span
|
<Link
|
||||||
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4"
|
href="/"
|
||||||
|
aria-label="返回首页"
|
||||||
|
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4 transition-transform duration-300 hover:rotate-[-6deg]"
|
||||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
<MessagesSquare size={26} />
|
<MessagesSquare size={26} />
|
||||||
</span>
|
</Link>
|
||||||
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||||
欢迎回来
|
欢迎回来
|
||||||
</h1>
|
</h1>
|
||||||
@@ -72,28 +74,32 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
|
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
|
||||||
{error && <div className="alert-error">{error}</div>}
|
{error && <div className="alert-error" role="alert">{error}</div>}
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">用户名</label>
|
<label className="field-label" htmlFor="login-username">用户名</label>
|
||||||
<input
|
<input
|
||||||
|
id="login-username"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
className="field"
|
className="field"
|
||||||
placeholder="testuser"
|
placeholder="你的用户名"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">密码</label>
|
<label className="field-label" htmlFor="login-password">密码</label>
|
||||||
<input
|
<input
|
||||||
|
id="login-password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
className="field"
|
className="field"
|
||||||
placeholder="••••••"
|
placeholder="输入密码"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
|
||||||
|
|||||||
124
frontend/app/notifications/NotificationsClient.tsx
Normal file
124
frontend/app/notifications/NotificationsClient.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Heart, MessageCircle, CheckCheck, Bell } from "lucide-react";
|
||||||
|
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知列表交互层:数据由服务端 SSR 直出,这里只承担已读标记等客户端操作。
|
||||||
|
*/
|
||||||
|
export default function NotificationsClient({ initialData }: { initialData: NotificationsResponse }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [notifications, setNotifications] = useState<NotificationItem[]>(initialData.notifications || []);
|
||||||
|
|
||||||
|
const handleClick = async (n: NotificationItem) => {
|
||||||
|
if (!n.is_read) {
|
||||||
|
apiMarkRead(n.id).catch(() => {});
|
||||||
|
setNotifications((list) =>
|
||||||
|
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
router.push(`/post/${n.post_id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAll = async () => {
|
||||||
|
await apiMarkAllRead();
|
||||||
|
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
|
||||||
|
// 同步刷新 Header 未读红点(layout 重新取 unread_count)
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const min = Math.floor(diff / 60000);
|
||||||
|
if (min < 1) return "刚刚";
|
||||||
|
if (min < 60) return `${min} 分钟前`;
|
||||||
|
const hour = Math.floor(min / 60);
|
||||||
|
if (hour < 24) return `${hour} 小时前`;
|
||||||
|
return `${Math.floor(hour / 24)} 天前`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUnread = notifications.some((n) => !n.is_read);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasUnread && (
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<button onClick={handleMarkAll} className="btn btn-line btn-sm">
|
||||||
|
<CheckCheck size={14} /> 全部已读
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notifications.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)" }}
|
||||||
|
>
|
||||||
|
<Bell size={26} />
|
||||||
|
</span>
|
||||||
|
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有通知</p>
|
||||||
|
<p className="meta mt-1.5">当有人评论或点赞你的帖子时,会在这里提醒你</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel overflow-hidden p-0">
|
||||||
|
{notifications.map((n, i) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
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)]"
|
||||||
|
style={{
|
||||||
|
borderTop: i === 0 ? "none" : "1px solid var(--line)",
|
||||||
|
background: n.is_read ? "transparent" : "var(--panel-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
color: n.type === "like" ? "var(--clay)" : "var(--accent)",
|
||||||
|
background:
|
||||||
|
n.type === "like" ? "var(--clay-soft)" : "var(--accent-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n.type === "like" ? <Heart size={16} /> : <MessageCircle size={16} />}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
|
||||||
|
<Link
|
||||||
|
href={`/u/${n.actor.id}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="font-semibold hover:text-[var(--accent)] transition-colors"
|
||||||
|
>
|
||||||
|
{n.actor.nickname}
|
||||||
|
</Link>{" "}
|
||||||
|
<span className="meta">
|
||||||
|
{n.type === "comment"
|
||||||
|
? "评论了你的帖子"
|
||||||
|
: n.type === "reply"
|
||||||
|
? "回复了你的评论"
|
||||||
|
: "点赞了你的帖子"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
|
||||||
|
《{n.post.title}》
|
||||||
|
</p>
|
||||||
|
{n.content && (
|
||||||
|
<p className="text-[13px] mt-1.5 line-clamp-2" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{n.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="meta text-xs mt-2">{formatTime(n.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
{!n.is_read && (
|
||||||
|
<span className="w-2.5 h-2.5 shrink-0 mt-2 rounded-full" style={{ background: "var(--clay)" }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,62 +1,34 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Heart, MessageCircle, CheckCheck, Bell, ChevronRight } from "lucide-react";
|
import { cookies } from "next/headers";
|
||||||
import {
|
import { ChevronRight } from "lucide-react";
|
||||||
apiFetchNotifications,
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
apiMarkRead,
|
import { getMeCached } from "@/lib/serverData";
|
||||||
apiMarkAllRead,
|
import { fetchNotifications } from "@/lib/api";
|
||||||
type NotificationItem,
|
import Pagination from "@/components/Pagination";
|
||||||
} from "@/lib/api";
|
import NotificationsClient from "./NotificationsClient";
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
interface NotificationsPageProps {
|
||||||
const router = useRouter();
|
searchParams: Promise<{ page?: string }>;
|
||||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
}
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const load = async () => {
|
// SSR 直出通知列表:F5 直接看到内容,不再先渲染"正在加载…"
|
||||||
setLoading(true);
|
export default async function NotificationsPage({ searchParams }: NotificationsPageProps) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user) {
|
||||||
|
redirect(`/login?redirect=${encodeURIComponent(`/notifications`)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
let loadError = false;
|
||||||
try {
|
try {
|
||||||
const res = await apiFetchNotifications(1);
|
data = await fetchNotifications(page, cookie);
|
||||||
setNotifications(res.notifications || []);
|
|
||||||
} catch {
|
} catch {
|
||||||
setNotifications([]);
|
loadError = true;
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClick = async (n: NotificationItem) => {
|
|
||||||
if (!n.is_read) {
|
|
||||||
apiMarkRead(n.id).catch(() => {});
|
|
||||||
setNotifications((list) =>
|
|
||||||
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
router.push(`/post/${n.post_id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkAll = async () => {
|
|
||||||
await apiMarkAllRead();
|
|
||||||
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
|
||||||
const min = Math.floor(diff / 60000);
|
|
||||||
if (min < 1) return "刚刚";
|
|
||||||
if (min < 60) return `${min} 分钟前`;
|
|
||||||
const hour = Math.floor(min / 60);
|
|
||||||
if (hour < 24) return `${hour} 小时前`;
|
|
||||||
return `${Math.floor(hour / 24)} 天前`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasUnread = notifications.some((n) => !n.is_read);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
@@ -66,81 +38,26 @@ export default function NotificationsPage() {
|
|||||||
<span style={{ color: "var(--accent)" }}>通知中心</span>
|
<span style={{ color: "var(--accent)" }}>通知中心</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-end justify-between mb-6 gap-4">
|
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight mb-6" style={{ color: "var(--ink)" }}>
|
||||||
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
|
||||||
通知中心
|
通知中心
|
||||||
</h1>
|
</h1>
|
||||||
{hasUnread && (
|
|
||||||
<button onClick={handleMarkAll} className="btn btn-line btn-sm">
|
|
||||||
<CheckCheck size={14} /> 全部已读
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loadError || !data ? (
|
||||||
<div className="panel text-center py-16">
|
<div className="panel text-center py-16 px-6" role="alert">
|
||||||
<p className="meta">正在加载通知…</p>
|
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>通知加载失败</p>
|
||||||
</div>
|
<p className="meta mt-1.5 mb-4">网络异常,请稍后重试</p>
|
||||||
) : notifications.length === 0 ? (
|
<Link href="/notifications" className="btn btn-line btn-sm">重新加载</Link>
|
||||||
<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)" }}
|
|
||||||
>
|
|
||||||
<Bell size={26} />
|
|
||||||
</span>
|
|
||||||
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有通知</p>
|
|
||||||
<p className="meta mt-1.5">当有人评论或点赞你的帖子时,会在这里提醒你</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="panel overflow-hidden p-0">
|
<>
|
||||||
{notifications.map((n, i) => (
|
<NotificationsClient initialData={data} />
|
||||||
<button
|
<Pagination
|
||||||
key={n.id}
|
total={data.total}
|
||||||
onClick={() => handleClick(n)}
|
page={data.page}
|
||||||
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)]"
|
size={data.size}
|
||||||
style={{
|
basePath="/notifications"
|
||||||
borderTop: i === 0 ? "none" : "1px solid var(--line)",
|
/>
|
||||||
background: n.is_read ? "transparent" : "var(--panel-2)",
|
</>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
color: n.type === "like" ? "var(--clay)" : "var(--accent)",
|
|
||||||
background:
|
|
||||||
n.type === "like" ? "var(--clay-soft)" : "var(--accent-soft)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{n.type === "like" ? <Heart size={16} /> : <MessageCircle size={16} />}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
|
|
||||||
<Link
|
|
||||||
href={`/u/${n.actor.id}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="font-semibold hover:text-[var(--accent)] transition-colors"
|
|
||||||
>
|
|
||||||
{n.actor.nickname}
|
|
||||||
</Link>{" "}
|
|
||||||
<span className="meta">{n.type === "comment" ? "评论了你的帖子" : "点赞了你的帖子"}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
|
|
||||||
《{n.post.title}》
|
|
||||||
</p>
|
|
||||||
{n.content && (
|
|
||||||
<p className="text-[13px] mt-1.5 line-clamp-2" style={{ color: "var(--ink-3)" }}>
|
|
||||||
{n.content}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="meta text-xs mt-2">{formatTime(n.created_at)}</p>
|
|
||||||
</div>
|
|
||||||
{!n.is_read && (
|
|
||||||
<span className="w-2.5 h-2.5 shrink-0 mt-2 rounded-full" style={{ background: "var(--clay)" }} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
stats={overview.stats}
|
stats={overview.stats}
|
||||||
hot={overview.hot}
|
hot={overview.hot}
|
||||||
activeUsers={overview.active_users}
|
activeUsers={overview.active_users}
|
||||||
latestComments={overview.latest_comments}
|
announcements={overview.announcements}
|
||||||
newUsers={overview.new_users}
|
newUsers={overview.new_users}
|
||||||
checkin={overview.checkin ?? undefined}
|
checkin={overview.checkin ?? undefined}
|
||||||
/>
|
/>
|
||||||
@@ -203,7 +203,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
<CommunityPanelsMobile
|
<CommunityPanelsMobile
|
||||||
hot={overview.hot}
|
hot={overview.hot}
|
||||||
activeUsers={overview.active_users}
|
activeUsers={overview.active_users}
|
||||||
latestComments={overview.latest_comments}
|
announcements={overview.announcements}
|
||||||
newUsers={overview.new_users}
|
newUsers={overview.new_users}
|
||||||
checkin={overview.checkin ?? undefined}
|
checkin={overview.checkin ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,16 +23,20 @@ import {
|
|||||||
type Board,
|
type Board,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import CommentSection from "@/components/CommentSection";
|
import CommentSection from "@/components/CommentSection";
|
||||||
import PostActions from "@/components/PostActions";
|
import PostActions from "@/components/PostActions";
|
||||||
import LikeButton from "@/components/LikeButton";
|
import LikeButton from "@/components/LikeButton";
|
||||||
import ReadingProgress from "@/components/ReadingProgress";
|
import ReadingProgress from "@/components/ReadingProgress";
|
||||||
import PostToc, { type TocItem } from "@/components/PostToc";
|
import PostToc, { type TocItem } from "@/components/PostToc";
|
||||||
|
import MobilePostBar from "@/components/MobilePostBar";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import { IconComment, IconEye } from "@/components/Icons";
|
import { IconComment, IconEye } from "@/components/Icons";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
|
// page 同时用于评论分页(?page=N),帖子正文本身不分页
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
@@ -313,14 +317,17 @@ function AuthorCardStacked({ profile }: { profile: UserProfile }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PostDetailPage({ params }: PageProps) {
|
export default async function PostDetailPage({ params, searchParams }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
|
const commentPage = Math.max(1, parseInt(sp.page || "1", 10) || 1);
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const { post } = await fetchPostDetail(id, cookie);
|
const { post } = await fetchPostDetail(id, cookie);
|
||||||
|
|
||||||
// 评论 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
|
// 评论 / 当前用户 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
|
||||||
const [commentsData, profile, relatedData] = await Promise.all([
|
const [commentsData, me, profile, relatedData] = await Promise.all([
|
||||||
fetchComments(id),
|
fetchComments(id, commentPage),
|
||||||
|
getMeCached(cookie || undefined),
|
||||||
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
||||||
fetchPosts(1, 8, post.board_id, "hot", "", false, cookie).catch(() => null),
|
fetchPosts(1, 8, post.board_id, "hot", "", false, cookie).catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -369,6 +376,8 @@ export default async function PostDetailPage({ params }: PageProps) {
|
|||||||
<>
|
<>
|
||||||
<ReadingProgress />
|
<ReadingProgress />
|
||||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
{/* 移动端浮动操作条:长文阅读中途可点赞 / 跳评论 / 回顶部 */}
|
||||||
|
<MobilePostBar postId={id} liked={post.liked} likeCount={post.like_count} />
|
||||||
|
|
||||||
{/* 三栏阅读布局:xl = 左导航240 + 正文 + 右辅助280;lg = 正文 + 右辅助260;以下单栏 */}
|
{/* 三栏阅读布局:xl = 左导航240 + 正文 + 右辅助280;lg = 正文 + 右辅助260;以下单栏 */}
|
||||||
<div className="grid grid-cols-1 items-start lg:grid-cols-[minmax(0,1fr)_260px] xl:grid-cols-[240px_minmax(0,1fr)_280px] gap-x-6 xl:gap-x-7">
|
<div className="grid grid-cols-1 items-start lg:grid-cols-[minmax(0,1fr)_260px] xl:grid-cols-[240px_minmax(0,1fr)_280px] gap-x-6 xl:gap-x-7">
|
||||||
@@ -487,7 +496,15 @@ export default async function PostDetailPage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CommentSection postId={id} comments={comments} />
|
<CommentSection
|
||||||
|
postId={id}
|
||||||
|
comments={comments}
|
||||||
|
total={commentsData.total}
|
||||||
|
totalComments={commentsData.total_comments}
|
||||||
|
page={commentsData.page}
|
||||||
|
size={commentsData.size}
|
||||||
|
user={me.user}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 窄屏相关讨论(lg 起由右栏承载) */}
|
{/* 窄屏相关讨论(lg 起由右栏承载) */}
|
||||||
<div className="lg:hidden mt-6">
|
<div className="lg:hidden mt-6">
|
||||||
@@ -505,6 +522,9 @@ export default async function PostDetailPage({ params }: PageProps) {
|
|||||||
<RelatedPosts posts={related} boardId={post.board_id} />
|
<RelatedPosts posts={related} boardId={post.board_id} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}
|
||||||
|
<div className="lg:hidden h-16" aria-hidden />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ export default function RegisterPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiRegister(username, email, password);
|
const res = await apiRegister(username, email, password);
|
||||||
if (res.id) {
|
if (res.user) {
|
||||||
|
// 后端注册即签发登录态 cookie:直接进入社区,无需再手动登录
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
} else if (res.id) {
|
||||||
|
// 极端情况:注册成功但自动签发失败,引导去登录
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
} else {
|
} else {
|
||||||
setError(res.error || "注册失败");
|
setError(res.error || "注册失败");
|
||||||
@@ -59,10 +64,11 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
|
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
|
||||||
{error && <div className="alert-error">{error}</div>}
|
{error && <div className="alert-error" role="alert">{error}</div>}
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">用户名</label>
|
<label className="field-label" htmlFor="reg-username">用户名</label>
|
||||||
<input
|
<input
|
||||||
|
id="reg-username"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
@@ -70,28 +76,33 @@ export default function RegisterPage() {
|
|||||||
minLength={3}
|
minLength={3}
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
className="field"
|
className="field"
|
||||||
placeholder="3–32 位"
|
placeholder="3–32 位"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">邮箱(可选)</label>
|
<label className="field-label" htmlFor="reg-email">邮箱(可选)</label>
|
||||||
<input
|
<input
|
||||||
|
id="reg-email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
className="field"
|
className="field"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">密码</label>
|
<label className="field-label" htmlFor="reg-password">密码</label>
|
||||||
<input
|
<input
|
||||||
|
id="reg-password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
|
autoComplete="new-password"
|
||||||
className="field"
|
className="field"
|
||||||
placeholder="至少 6 位"
|
placeholder="至少 6 位"
|
||||||
/>
|
/>
|
||||||
|
|||||||
106
frontend/app/settings/PasswordForm.tsx
Normal file
106
frontend/app/settings/PasswordForm.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,71 +1,19 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { apiChangePassword, apiMe, type User } from "@/lib/api";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
|
import PasswordForm from "./PasswordForm";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
// SSR 直出账号信息:未登录直接跳登录页,登录后 F5 不再有"正在加载…"闪动
|
||||||
const router = useRouter();
|
export default async function SettingsPage() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const cookie = authCookieHeader(await cookies());
|
||||||
const [oldPassword, setOldPassword] = useState("");
|
const me = await getMeCached(cookie || undefined);
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const user = me.user;
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
apiMe()
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.user) {
|
|
||||||
router.push("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUser(res.user);
|
|
||||||
})
|
|
||||||
.catch(() => router.push("/login"));
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
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 (e) {
|
|
||||||
setError("网络错误,请重试");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
redirect(`/login?redirect=${encodeURIComponent(`/settings`)}`);
|
||||||
<div className="max-w-md mx-auto text-center py-20">
|
|
||||||
<p className="meta">正在加载…</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,51 +40,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="font-bold text-[15px] mb-3.5" style={{ color: "var(--ink)" }}>修改密码</p>
|
<PasswordForm />
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="panel p-5 sm:p-6 space-y-4">
|
|
||||||
{success && <div className="alert-ok">密码修改成功,正在跳转登录页...</div>}
|
|
||||||
{error && <div className="alert-error">{error}</div>}
|
|
||||||
<div>
|
|
||||||
<label className="field-label">当前密码</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={oldPassword}
|
|
||||||
onChange={(e) => setOldPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
className="field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="field-label">新密码(至少 6 位)</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={6}
|
|
||||||
className="field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="field-label">确认新密码</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={6}
|
|
||||||
className="field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting || success}
|
|
||||||
className="btn btn-primary w-full justify-center mt-2"
|
|
||||||
>
|
|
||||||
{submitting ? "提交中..." : "修改密码"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { FileText, MessageCircle } from "lucide-react";
|
|
||||||
import { fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api";
|
|
||||||
import Pagination from "@/components/Pagination";
|
|
||||||
import { IconComment, IconEye, IconHeart } from "@/components/Icons";
|
|
||||||
|
|
||||||
interface UserProfileClientProps {
|
|
||||||
userId: string;
|
|
||||||
initialPosts: UserProfile;
|
|
||||||
initialPage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tab = "posts" | "comments";
|
|
||||||
|
|
||||||
export default function UserProfileClient({ userId, initialPosts, initialPage }: UserProfileClientProps) {
|
|
||||||
const [tab, setTab] = useState<Tab>("posts");
|
|
||||||
const [commentData, setCommentData] = useState<UserCommentsResponse | null>(null);
|
|
||||||
const [commentLoading, setCommentLoading] = useState(false);
|
|
||||||
const [commentPage, setCommentPage] = useState(1);
|
|
||||||
|
|
||||||
const loadComments = async (page: number) => {
|
|
||||||
setCommentLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await fetchUserComments(userId, page);
|
|
||||||
setCommentData({ ...data, comments: data.comments || [] });
|
|
||||||
setCommentPage(page);
|
|
||||||
} catch {
|
|
||||||
setCommentData({ comments: [], total: 0, page, size: 20 });
|
|
||||||
}
|
|
||||||
setCommentLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTabChange = (t: Tab) => {
|
|
||||||
setTab(t);
|
|
||||||
if (t === "comments" && commentData === null) {
|
|
||||||
loadComments(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabBtn = (t: Tab, label: string, count: number, icon: React.ReactNode) => {
|
|
||||||
const active = tab === t;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => handleTabChange(t)}
|
|
||||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-full text-sm font-semibold transition-all"
|
|
||||||
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"
|
|
||||||
style={{
|
|
||||||
background: active ? "var(--accent-soft)" : "var(--tint)",
|
|
||||||
color: active ? "var(--accent)" : "var(--ink-3)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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)" }}
|
|
||||||
>
|
|
||||||
{tabBtn("posts", "发帖", initialPosts.stats.post_count, <FileText size={15} />)}
|
|
||||||
{tabBtn("comments", "评论", initialPosts.stats.comment_count, <MessageCircle size={15} />)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "posts" ? (
|
|
||||||
<div>
|
|
||||||
{initialPosts.posts.length > 0 ? (
|
|
||||||
<div className="panel overflow-hidden p-0">
|
|
||||||
{initialPosts.posts.map((post, i) => (
|
|
||||||
<Link
|
|
||||||
key={post.id}
|
|
||||||
href={`/post/${post.id}`}
|
|
||||||
className="block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]"
|
|
||||||
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
||||||
<span className="chip">{post.board.name}</span>
|
|
||||||
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
|
|
||||||
{post.recommended && <span className="badge-rec">推荐</span>}
|
|
||||||
</div>
|
|
||||||
<h2
|
|
||||||
className="font-semibold mb-2 leading-snug group-hover:text-[var(--accent)]"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
|
||||||
{post.title}
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-center gap-4 meta text-[12px]">
|
|
||||||
<span>{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<IconComment size={12} /> {post.comment_count}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<IconEye size={12} /> {post.view_count}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<IconHeart size={12} /> {post.like_count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="panel text-center py-16 px-6">
|
|
||||||
<FileText size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
|
||||||
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>暂无发帖</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Pagination
|
|
||||||
total={initialPosts.posts_total}
|
|
||||||
page={initialPage}
|
|
||||||
size={20}
|
|
||||||
basePath={`/u/${userId}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{commentLoading ? (
|
|
||||||
<div className="panel text-center py-16">
|
|
||||||
<p className="meta">正在加载评论…</p>
|
|
||||||
</div>
|
|
||||||
) : commentData && commentData.comments.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="panel overflow-hidden p-0">
|
|
||||||
{commentData.comments.map((c, i) => (
|
|
||||||
<Link
|
|
||||||
key={c.id}
|
|
||||||
href={`/post/${c.post_id}`}
|
|
||||||
className="block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]"
|
|
||||||
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
|
||||||
>
|
|
||||||
<div className="meta text-xs mb-2">
|
|
||||||
评论于《<span style={{ color: "var(--accent)" }}>{c.post_title}</span>》
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className="text-[15px] leading-relaxed line-clamp-2"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
|
||||||
{c.content}
|
|
||||||
</p>
|
|
||||||
<div className="meta text-xs mt-2">
|
|
||||||
{new Date(c.created_at).toLocaleString("zh-CN")}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Pagination
|
|
||||||
total={commentData.total}
|
|
||||||
page={commentPage}
|
|
||||||
size={20}
|
|
||||||
basePath={`/u/${userId}`}
|
|
||||||
query={{ tab: "comments" }}
|
|
||||||
onChange={loadComments}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="panel text-center py-16 px-6">
|
|
||||||
<MessageCircle size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
|
||||||
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>暂无评论</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
165
frontend/app/u/[id]/UserProfileContent.tsx
Normal file
165
frontend/app/u/[id]/UserProfileContent.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { FileText, MessageCircle, AlertCircle } from "lucide-react";
|
||||||
|
import Pagination from "@/components/Pagination";
|
||||||
|
import { IconComment, IconEye, IconHeart } from "@/components/Icons";
|
||||||
|
import type { UserProfile, UserCommentsResponse } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户主页内容区(纯服务端渲染):
|
||||||
|
* tab 与分页全部走 URL(?tab=comments&page=N),刷新/后退/分享链接均保持一致。
|
||||||
|
*/
|
||||||
|
export default function UserProfileContent({
|
||||||
|
userId,
|
||||||
|
profile,
|
||||||
|
tab,
|
||||||
|
commentData,
|
||||||
|
commentError,
|
||||||
|
page,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
profile: UserProfile;
|
||||||
|
tab: "posts" | "comments";
|
||||||
|
commentData: UserCommentsResponse | null;
|
||||||
|
commentError: boolean;
|
||||||
|
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 (
|
||||||
|
<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" ? (
|
||||||
|
<div>
|
||||||
|
{profile.posts.length > 0 ? (
|
||||||
|
<div className="panel overflow-hidden p-0">
|
||||||
|
{profile.posts.map((post, i) => (
|
||||||
|
<Link
|
||||||
|
key={post.id}
|
||||||
|
href={`/post/${post.id}`}
|
||||||
|
className="group block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]"
|
||||||
|
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="j13-board-tag">{post.board.name}</span>
|
||||||
|
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
|
||||||
|
{post.recommended && <span className="badge-rec">推荐</span>}
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="font-semibold mb-2 leading-snug transition-colors group-hover:text-[var(--accent)]"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-4 meta text-[12px]">
|
||||||
|
<span className="tabular-nums">{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<IconComment size={12} /> {post.comment_count}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<IconEye size={12} /> {post.view_count}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<IconHeart size={12} /> {post.like_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel text-center py-16 px-6">
|
||||||
|
<FileText size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
||||||
|
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>暂无发帖</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Pagination
|
||||||
|
total={profile.posts_total}
|
||||||
|
page={page}
|
||||||
|
size={20}
|
||||||
|
basePath={`/u/${userId}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : commentError ? (
|
||||||
|
<div className="panel text-center py-16 px-6" role="alert">
|
||||||
|
<AlertCircle size={26} className="mx-auto mb-3" style={{ color: "var(--danger)" }} />
|
||||||
|
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>评论加载失败</p>
|
||||||
|
<p className="meta mt-1.5 mb-4">网络异常,请稍后重试</p>
|
||||||
|
<Link href={`/u/${userId}?tab=comments`} className="btn btn-line btn-sm">重新加载</Link>
|
||||||
|
</div>
|
||||||
|
) : commentData && commentData.comments.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="panel overflow-hidden p-0">
|
||||||
|
{commentData.comments.map((c, i) => (
|
||||||
|
<Link
|
||||||
|
key={c.id}
|
||||||
|
href={`/post/${c.post_id}`}
|
||||||
|
className="block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]"
|
||||||
|
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<div className="meta text-xs mb-2">
|
||||||
|
评论于《<span style={{ color: "var(--accent)" }}>{c.post_title}</span>》
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-[15px] leading-relaxed line-clamp-2"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{c.content}
|
||||||
|
</p>
|
||||||
|
<div className="meta text-xs mt-2 tabular-nums">
|
||||||
|
{new Date(c.created_at).toLocaleString("zh-CN")}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
total={commentData.total}
|
||||||
|
page={commentData.page}
|
||||||
|
size={20}
|
||||||
|
basePath={`/u/${userId}`}
|
||||||
|
query={{ tab: "comments" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel text-center py-16 px-6">
|
||||||
|
<MessageCircle size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
||||||
|
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>暂无评论</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { ChevronRight, FileText, MessageCircle } from "lucide-react";
|
import { ChevronRight, FileText, MessageCircle, Flame, AlertCircle } from "lucide-react";
|
||||||
import { fetchUserProfile, type UserProfile } from "@/lib/api";
|
import { fetchUserProfile, fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api";
|
||||||
import UserProfileClient from "./UserProfileClient";
|
import UserProfileContent from "./UserProfileContent";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
|
|
||||||
interface UserPageProps {
|
interface UserPageProps {
|
||||||
@@ -25,8 +25,9 @@ export async function generateMetadata({ params }: UserPageProps): Promise<Metad
|
|||||||
|
|
||||||
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 { page: pageStr } = await searchParams;
|
const sp = await searchParams;
|
||||||
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
|
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
|
||||||
|
const tab: "posts" | "comments" = sp.tab === "comments" ? "comments" : "posts";
|
||||||
|
|
||||||
let data: UserProfile;
|
let data: UserProfile;
|
||||||
try {
|
try {
|
||||||
@@ -45,11 +46,23 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 评论 tab:SSR 直出对应分页,URL(?tab=comments&page=N)与内容严格一致
|
||||||
|
let commentData: UserCommentsResponse | null = null;
|
||||||
|
let commentError = false;
|
||||||
|
if (tab === "comments") {
|
||||||
|
try {
|
||||||
|
commentData = await fetchUserComments(id, page);
|
||||||
|
} catch {
|
||||||
|
commentError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const u = data.user;
|
const u = data.user;
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "发帖", value: data.stats.post_count, icon: <FileText size={15} /> },
|
{ label: "发帖", value: data.stats.post_count, icon: <FileText size={15} /> },
|
||||||
{ label: "评论", value: data.stats.comment_count, icon: <MessageCircle size={15} /> },
|
{ label: "评论", value: data.stats.comment_count, icon: <MessageCircle size={15} /> },
|
||||||
];
|
];
|
||||||
|
const showPoints = data.stats.points > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
@@ -70,8 +83,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
<Avatar
|
<Avatar
|
||||||
name={u.nickname || u.username}
|
name={u.nickname || u.username}
|
||||||
src={u.avatar || undefined}
|
src={u.avatar || undefined}
|
||||||
size={80}
|
size={88}
|
||||||
className="sm:!w-[88px] sm:!h-[88px]"
|
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2.5 flex-wrap">
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
@@ -90,7 +102,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
<p className="meta mt-1.5 text-[13px]">
|
<p className="meta mt-1.5 text-[13px]">
|
||||||
@{u.username} · {new Date(u.created_at).toLocaleDateString("zh-CN")} 加入
|
@{u.username} · {new Date(u.created_at).toLocaleDateString("zh-CN")} 加入
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2.5 mt-4">
|
<div className="flex gap-2.5 mt-4 flex-wrap">
|
||||||
{stats.map((s) => (
|
{stats.map((s) => (
|
||||||
<span
|
<span
|
||||||
key={s.label}
|
key={s.label}
|
||||||
@@ -98,16 +110,34 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
|
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
|
||||||
>
|
>
|
||||||
<span style={{ color: "var(--accent)" }}>{s.icon}</span>
|
<span style={{ color: "var(--accent)" }}>{s.icon}</span>
|
||||||
{s.value} {s.label}
|
<span className="tabular-nums">{s.value}</span> {s.label}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 发帖 / 评论 切换 */}
|
{/* 发帖 / 评论 切换(URL 驱动,SSR 直出) */}
|
||||||
<UserProfileClient userId={id} initialPosts={data} initialPage={page} />
|
<UserProfileContent
|
||||||
|
userId={id}
|
||||||
|
profile={data}
|
||||||
|
tab={tab}
|
||||||
|
commentData={commentData}
|
||||||
|
commentError={commentError}
|
||||||
|
page={page}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,54 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Trash2, MessagesSquare } from "lucide-react";
|
import { Trash2, MessagesSquare, ChevronRight, ChevronDown } from "lucide-react";
|
||||||
import { apiCreateComment, apiDeleteComment, apiMe, type Comment, type User } from "@/lib/api";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { apiCreateComment, apiDeleteComment, type CommentNode, type User } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import Pagination from "./Pagination";
|
||||||
|
|
||||||
interface CommentSectionProps {
|
interface CommentSectionProps {
|
||||||
postId: string;
|
postId: string;
|
||||||
comments: Comment[];
|
comments: CommentNode[];
|
||||||
|
total: number; // 楼层数(主评论数)→ 分页与楼层号计算
|
||||||
|
totalComments: number; // 全部评论数(含回复)→ 头部徽标
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
// SSR 直出的登录用户(layout 已取,避免客户端再请求一次 /api/me)
|
||||||
|
user: User | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CommentSection({ postId, comments }: CommentSectionProps) {
|
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
|
||||||
|
const CONFIRM_RESET_MS = 3000;
|
||||||
|
|
||||||
|
// 递归统计子树回复数(删除级联提示用)
|
||||||
|
function countReplies(node: CommentNode): number {
|
||||||
|
return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentSection({
|
||||||
|
postId,
|
||||||
|
comments,
|
||||||
|
total,
|
||||||
|
totalComments,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
user,
|
||||||
|
}: CommentSectionProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
// 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框)
|
||||||
|
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
|
||||||
|
const [replyContent, setReplyContent] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
|
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||||
useEffect(() => {
|
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||||
apiMe()
|
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||||
.then((res) => setUser(res.user || null))
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
.catch(() => setUser(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -34,6 +58,7 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
|||||||
const res = await apiCreateComment(postId, content);
|
const res = await apiCreateComment(postId, content);
|
||||||
if (res.comment) {
|
if (res.comment) {
|
||||||
setContent("");
|
setContent("");
|
||||||
|
// 新楼层在最后一页:刷新后由服务端直出最新分页
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else if (res.error === "未登录") {
|
} else if (res.error === "未登录") {
|
||||||
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
|
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
|
||||||
@@ -47,8 +72,38 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!replyTarget || !replyContent.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await apiCreateComment(postId, replyContent, replyTarget.id);
|
||||||
|
if (res.comment) {
|
||||||
|
setReplyTarget(null);
|
||||||
|
setReplyContent("");
|
||||||
|
// 刷新后由 SSR 直出新树(子评论全量挂在本楼)
|
||||||
|
router.refresh();
|
||||||
|
} else if (res.error === "未登录") {
|
||||||
|
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
|
||||||
|
} else {
|
||||||
|
toast(res.error || "回复失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast("网络错误,回复失败");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (commentId: number) => {
|
const handleDelete = async (commentId: number) => {
|
||||||
if (!confirm("确定要删除这条评论吗?")) return;
|
if (confirmingId !== commentId) {
|
||||||
|
setConfirmingId(commentId);
|
||||||
|
setTimeout(() => {
|
||||||
|
setConfirmingId((cur) => (cur === commentId ? null : cur));
|
||||||
|
}, CONFIRM_RESET_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmingId(null);
|
||||||
setDeletingId(commentId);
|
setDeletingId(commentId);
|
||||||
try {
|
try {
|
||||||
const res = await apiDeleteComment(postId, commentId);
|
const res = await apiDeleteComment(postId, commentId);
|
||||||
@@ -64,26 +119,271 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canDelete = (comment: Comment) =>
|
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
|
||||||
|
|
||||||
|
const canDelete = (comment: CommentNode) =>
|
||||||
user && (user.id === comment.user.id || user.role === "admin");
|
user && (user.id === comment.user.id || user.role === "admin");
|
||||||
|
|
||||||
|
const openReply = (c: CommentNode) => {
|
||||||
|
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
|
||||||
|
setReplyContent("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换某楼层子回复的展开/折叠
|
||||||
|
const toggleFloor = (floorId: number) => {
|
||||||
|
setExpandedFloors((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(floorId)) next.delete(floorId);
|
||||||
|
else next.add(floorId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||||
|
|
||||||
|
// 内联回复表单(出现在目标评论正下方)
|
||||||
|
const inlineReplyForm = replyTarget && (
|
||||||
|
<form onSubmit={handleSubmitReply} className="cmt-reply-form mt-3">
|
||||||
|
<div className="text-[12px] mb-2" style={{ color: "var(--ink-3)" }}>
|
||||||
|
回复 <span style={{ color: "var(--accent)" }}>@{replyTarget.nickname}</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={replyContent}
|
||||||
|
onChange={(e) => setReplyContent(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={2}
|
||||||
|
autoFocus
|
||||||
|
className="field"
|
||||||
|
aria-label={`回复 ${replyTarget.nickname}`}
|
||||||
|
placeholder="友善回复..."
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-3 mt-2">
|
||||||
|
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setReplyTarget(null);
|
||||||
|
setReplyContent("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={submitting || !replyContent.trim()} className="btn btn-primary btn-sm">
|
||||||
|
{submitting ? "发送中..." : "回复"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 单条评论渲染(递归):depth 0 = 楼层主评论,>0 = 子回复
|
||||||
|
// 主评论下方子回复默认折叠,点击"展开 X 条回复"展开
|
||||||
|
// depth=1(回复楼层)不显示指向;depth≥2(回复子评论)显示"回复者 ▶ 被回复者"
|
||||||
|
const renderComment = (node: CommentNode, depth: number, floor?: number, parent?: CommentNode) => {
|
||||||
|
const c = node;
|
||||||
|
const replyCount = countReplies(node);
|
||||||
|
const isFloor = depth === 0;
|
||||||
|
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
|
||||||
|
const parentAuthor = parent?.user.nickname || parent?.user.username;
|
||||||
|
|
||||||
|
if (!isFloor) {
|
||||||
|
// 子回复:紧凑行式布局(抖音风格)
|
||||||
|
const isReplyToReply = depth >= 2;
|
||||||
return (
|
return (
|
||||||
<section className="panel p-6 sm:p-8 mt-6">
|
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<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} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Link href={`/u/${c.user.id}`} className="group shrink-0">
|
||||||
|
<span className="cmt-reply-name group-hover:text-[var(--accent)] transition-colors">
|
||||||
|
{c.user.nickname || c.user.username}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{isReplyToReply && parent && (
|
||||||
|
<>
|
||||||
|
<ChevronRight size={12} className="cmt-reply-arrow" aria-hidden="true" />
|
||||||
|
<a href={`#comment-${parent.id}`} className="cmt-reply-target">
|
||||||
|
{parentAuthor}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="prose-content prose-comment mt-0.5">
|
||||||
|
<ReactMarkdown>{c.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
{/* 底部行:时间 · 回复(左),删除(右) */}
|
||||||
|
<div className="flex items-center justify-between mt-1.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={() => openReply(c)}
|
||||||
|
className={`cmt-reply-btn transition-colors ${
|
||||||
|
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
|
||||||
|
}`}
|
||||||
|
aria-label={`回复 ${c.user.nickname || c.user.username}`}
|
||||||
|
title="回复"
|
||||||
|
>
|
||||||
|
回复
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canDelete(c) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(c.id)}
|
||||||
|
disabled={deletingId === c.id}
|
||||||
|
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
||||||
|
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
|
||||||
|
}`}
|
||||||
|
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||||
|
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||||
|
>
|
||||||
|
{confirmingId === c.id ? (
|
||||||
|
<span className="text-[11.5px] whitespace-nowrap">
|
||||||
|
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Trash2 size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{replyTarget?.id === c.id && inlineReplyForm}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{node.replies && node.replies.length > 0 && (
|
||||||
|
<div className="cmt-children">
|
||||||
|
{node.replies.map((r) => renderComment(r, depth + 1, undefined, node))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主评论(楼层)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
id={`comment-${c.id}`}
|
||||||
|
className="py-5 border-t first:border-t-0 scroll-mt-24"
|
||||||
|
style={{ borderColor: "var(--line)" }}
|
||||||
|
>
|
||||||
|
<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}>
|
||||||
|
<Avatar name={c.user.nickname || c.user.username} size={38} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2.5 mb-1.5">
|
||||||
|
<Link href={`/u/${c.user.id}`} className="group">
|
||||||
|
<span className="font-semibold truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}>
|
||||||
|
{c.user.nickname || c.user.username}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{floor && (
|
||||||
|
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
|
||||||
|
#{floor}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="prose-content prose-comment">
|
||||||
|
<ReactMarkdown>{c.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
{/* 底部行:时间 · 回复(左),删除(右) */}
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={() => openReply(c)}
|
||||||
|
className={`cmt-reply-btn transition-colors ${
|
||||||
|
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
|
||||||
|
}`}
|
||||||
|
aria-label={`回复 ${c.user.nickname || c.user.username}`}
|
||||||
|
title="回复"
|
||||||
|
>
|
||||||
|
回复
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canDelete(c) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(c.id)}
|
||||||
|
disabled={deletingId === c.id}
|
||||||
|
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
||||||
|
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
|
||||||
|
}`}
|
||||||
|
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||||
|
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||||
|
>
|
||||||
|
{confirmingId === c.id ? (
|
||||||
|
<span className="text-[11.5px] whitespace-nowrap">
|
||||||
|
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Trash2 size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{replyTarget?.id === c.id && inlineReplyForm}
|
||||||
|
|
||||||
|
{/* 折叠/展开子回复 */}
|
||||||
|
{node.replies && node.replies.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleFloor(c.id)}
|
||||||
|
className="cmt-fold-btn"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span>
|
||||||
|
{isExpanded ? "收起回复" : `展开 ${replyCount} 条回复`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="cmt-replies-wrap">
|
||||||
|
{node.replies.map((r) => renderComment(r, 1, undefined, node))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6 sm:p-8 mt-6" aria-label="评论区">
|
||||||
<div className="flex items-center gap-2.5 mb-6">
|
<div className="flex items-center gap-2.5 mb-6">
|
||||||
<h2 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
<h2 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||||
评论
|
评论
|
||||||
</h2>
|
</h2>
|
||||||
<span
|
<span
|
||||||
className="text-[12px] font-bold px-2 py-0.5 rounded-full"
|
className="text-[12px] font-bold px-2 py-0.5 rounded-full tabular-nums"
|
||||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
{comments.length}
|
{totalComments}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<form onSubmit={handleSubmit} className="mb-8">
|
<form onSubmit={handleSubmit} className="mb-8">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
id="comment-input"
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -92,9 +392,10 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
|||||||
e.currentTarget.form?.requestSubmit();
|
e.currentTarget.form?.requestSubmit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="友善发言,说说你的看法..."
|
placeholder="友善发言,发表一个新楼层..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="field"
|
className="field"
|
||||||
|
aria-label="写评论"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between gap-3 mt-2.5">
|
<div className="flex items-center justify-between gap-3 mt-2.5">
|
||||||
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
|
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
|
||||||
@@ -111,52 +412,14 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
|||||||
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
|
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
|
||||||
登录后参与讨论
|
登录后参与讨论
|
||||||
</span>
|
</span>
|
||||||
<Link href="/login" className="btn btn-line btn-sm">
|
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
|
||||||
去登录
|
去登录
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{comments.map((comment) => (
|
{comments.map((node, i) => renderComment(node, 0, (page - 1) * size + i + 1))}
|
||||||
<div
|
|
||||||
key={comment.id}
|
|
||||||
className="flex gap-3.5 py-5 border-t first:border-t-0"
|
|
||||||
style={{ borderColor: "var(--line)" }}
|
|
||||||
>
|
|
||||||
<Link href={`/u/${comment.user.id}`} className="shrink-0 mt-0.5" aria-label={comment.user.nickname}>
|
|
||||||
<Avatar name={comment.user.nickname || comment.user.username} size={38} />
|
|
||||||
</Link>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between gap-3 mb-1.5">
|
|
||||||
<div className="flex items-center gap-2.5 min-w-0 flex-wrap">
|
|
||||||
<Link href={`/u/${comment.user.id}`} className="group">
|
|
||||||
<span
|
|
||||||
className="text-sm font-semibold truncate group-hover:text-[var(--accent)] transition-colors"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
|
||||||
{comment.user.nickname}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<span className="meta text-xs">{new Date(comment.created_at).toLocaleString("zh-CN")}</span>
|
|
||||||
</div>
|
|
||||||
{canDelete(comment) && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(comment.id)}
|
|
||||||
disabled={deletingId === comment.id}
|
|
||||||
className="meta hover:text-[var(--danger)] transition-colors disabled:opacity-50 p-1"
|
|
||||||
aria-label="删除评论"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px] leading-relaxed" style={{ color: "var(--ink)" }}>
|
|
||||||
{comment.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{comments.length === 0 && (
|
{comments.length === 0 && (
|
||||||
<div className="text-center py-14">
|
<div className="text-center py-14">
|
||||||
<MessagesSquare size={28} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
<MessagesSquare size={28} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
||||||
@@ -167,6 +430,8 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination total={total} page={page} size={size} basePath={`/post/${postId}`} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Megaphone,
|
||||||
|
Layers,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiLogout, apiMe, type User } from "@/lib/api";
|
import { apiLogout, apiMe, type User, type Board } from "@/lib/api";
|
||||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
import NotificationBell from "./NotificationBell";
|
import NotificationBell from "./NotificationBell";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
@@ -28,10 +30,12 @@ export default function Header({
|
|||||||
initialUser,
|
initialUser,
|
||||||
initialUnread = 0,
|
initialUnread = 0,
|
||||||
initialTheme = "light",
|
initialTheme = "light",
|
||||||
|
boards = [],
|
||||||
}: {
|
}: {
|
||||||
initialUser: User | null;
|
initialUser: User | null;
|
||||||
initialUnread?: number;
|
initialUnread?: number;
|
||||||
initialTheme?: "light" | "dark";
|
initialTheme?: "light" | "dark";
|
||||||
|
boards?: Board[];
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -167,12 +171,18 @@ export default function Header({
|
|||||||
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
|
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
|
||||||
首页
|
首页
|
||||||
</NavPill>
|
</NavPill>
|
||||||
|
<BoardsMenu boards={boards} />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 中组:搜索居中 */}
|
{/* 中部留白:为后续主导航菜单项预留位置 */}
|
||||||
<form onSubmit={handleSearch} className="hidden lg:flex flex-1 justify-center">
|
<div className="hidden lg:block flex-1" aria-hidden />
|
||||||
<div className="relative w-full max-w-[380px] focus-within:max-w-[440px] transition-[max-width] duration-200">
|
|
||||||
|
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
|
||||||
|
桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
|
||||||
|
<div className="flex items-center gap-2 lg:gap-3">
|
||||||
|
<form onSubmit={handleSearch} className="hidden lg:block">
|
||||||
|
<div className="relative w-[220px] xl:w-[260px] focus-within:w-[268px] xl:focus-within:w-[300px] transition-[width] duration-200">
|
||||||
<Search
|
<Search
|
||||||
size={15}
|
size={15}
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2"
|
className="absolute left-4 top-1/2 -translate-y-1/2"
|
||||||
@@ -201,10 +211,6 @@ export default function Header({
|
|||||||
</kbd>
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* 右侧操作区:桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
|
|
||||||
桌面额外显示主题/发帖/用户菜单,移动端收进汉堡抽屉 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className={`${iconBtn} hidden lg:inline-flex`}
|
className={`${iconBtn} hidden lg:inline-flex`}
|
||||||
@@ -271,9 +277,47 @@ export default function Header({
|
|||||||
</MobileLink>
|
</MobileLink>
|
||||||
)}
|
)}
|
||||||
{user && <MobileLink href="/settings">账号设置</MobileLink>}
|
{user && <MobileLink href="/settings">账号设置</MobileLink>}
|
||||||
|
{user?.role === "admin" && (
|
||||||
|
<MobileLink href="/admin/announcements">
|
||||||
|
<span className="inline-flex items-center gap-2"><Megaphone size={15} /> 公告管理</span>
|
||||||
|
</MobileLink>
|
||||||
|
)}
|
||||||
{!user && <MobileLink href="/login">登录</MobileLink>}
|
{!user && <MobileLink href="/login">登录</MobileLink>}
|
||||||
{!user && <MobileLink href="/register">注册</MobileLink>}
|
{!user && <MobileLink href="/register">注册</MobileLink>}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 全站板块入口:手机用户在任意页面都能切板块,不必回首页 */}
|
||||||
|
{boards.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t" style={{ borderColor: "var(--line)" }}>
|
||||||
|
<p className="px-3 mb-1.5 text-[11.5px] font-bold tracking-[0.08em] uppercase" style={{ color: "var(--ink-3)" }}>
|
||||||
|
板块
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{boards.map((b, i) => {
|
||||||
|
const active = pathname === `/board/${b.id}`;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={b.id}
|
||||||
|
href={`/board/${b.id}`}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors"
|
||||||
|
style={{
|
||||||
|
color: active ? "var(--accent)" : "var(--ink)",
|
||||||
|
background: active ? "var(--accent-soft)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`av-${Math.max(0, b.color_index ?? i) % 8} w-6 h-6 rounded-lg flex items-center justify-center text-[11px] font-bold shrink-0`}
|
||||||
|
>
|
||||||
|
{b.name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{b.name}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
||||||
<button onClick={toggleTheme} className="btn btn-line btn-sm">
|
<button onClick={toggleTheme} className="btn btn-line btn-sm">
|
||||||
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
|
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
|
||||||
@@ -318,6 +362,96 @@ function NavPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 板块下拉菜单(桌面端):全站任意页面的 Header 都能直达板块,
|
||||||
|
* 替代原先只有首页左栏才有板块导航的布局局限
|
||||||
|
*/
|
||||||
|
function BoardsMenu({ boards }: { boards: Board[] }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (boards.length === 0) return null;
|
||||||
|
const active = pathname.startsWith("/board/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13.5px] font-semibold transition-colors"
|
||||||
|
style={
|
||||||
|
active || open
|
||||||
|
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||||
|
: { color: "var(--ink-2)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Layers size={14} />
|
||||||
|
板块
|
||||||
|
<ChevronDown
|
||||||
|
size={12}
|
||||||
|
className="transition-transform duration-200"
|
||||||
|
style={{ transform: open ? "rotate(180deg)" : "none" }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute left-0 mt-2.5 w-60 max-h-[60vh] overflow-y-auto p-1.5 z-50 j13-toast-in"
|
||||||
|
style={{
|
||||||
|
background: "var(--panel)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{boards.map((b, i) => {
|
||||||
|
const isActive = pathname === `/board/${b.id}`;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={b.id}
|
||||||
|
href={`/board/${b.id}`}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
role="menuitem"
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors hover:bg-[var(--panel-2)]"
|
||||||
|
style={{
|
||||||
|
color: isActive ? "var(--accent)" : "var(--ink)",
|
||||||
|
background: isActive ? "var(--nav-active-bg)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`av-${Math.max(0, b.color_index ?? i) % 8} w-7 h-7 rounded-lg flex items-center justify-center text-[12px] font-bold shrink-0`}
|
||||||
|
>
|
||||||
|
{b.name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{b.name}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MobileLink({
|
function MobileLink({
|
||||||
href,
|
href,
|
||||||
active,
|
active,
|
||||||
@@ -428,6 +562,13 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
|
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{user.role === "admin" && (
|
||||||
|
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||||
|
<Link href="/admin/announcements" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||||
|
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 公告管理
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
82
frontend/components/MobilePostBar.tsx
Normal file
82
frontend/components/MobilePostBar.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ArrowUp, MessageSquare } from "lucide-react";
|
||||||
|
import LikeButton from "./LikeButton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 帖子详情移动端浮动操作条(lg 以下显示):
|
||||||
|
* 长文读到中途也能点赞 / 跳评论框 / 回顶部,不必滚到文末。
|
||||||
|
*/
|
||||||
|
export default function MobilePostBar({
|
||||||
|
postId,
|
||||||
|
liked,
|
||||||
|
likeCount,
|
||||||
|
}: {
|
||||||
|
postId: string;
|
||||||
|
liked: boolean;
|
||||||
|
likeCount: number;
|
||||||
|
}) {
|
||||||
|
const [showTop, setShowTop] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setShowTop(window.scrollY > 400);
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToComments = () => {
|
||||||
|
document.getElementById("comment-input")?.scrollIntoView({
|
||||||
|
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||||
|
block: "center",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-40" role="navigation" aria-label="阅读操作">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-0.5 rounded-full border px-1.5 py-1.5"
|
||||||
|
style={{
|
||||||
|
background: "color-mix(in srgb, var(--panel) 90%, transparent)",
|
||||||
|
borderColor: "var(--line-2)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
backdropFilter: "blur(14px)",
|
||||||
|
WebkitBackdropFilter: "blur(14px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={scrollToComments}
|
||||||
|
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||||
|
style={{ color: "var(--ink-2)" }}
|
||||||
|
aria-label="跳到评论框"
|
||||||
|
>
|
||||||
|
<MessageSquare size={18} />
|
||||||
|
</button>
|
||||||
|
{/* 放大点赞按钮的触控区域(样式见 globals.css .j13-bar-like) */}
|
||||||
|
<div className="j13-bar-like">
|
||||||
|
<LikeButton postId={postId} liked={liked} likeCount={likeCount} variant="compact" />
|
||||||
|
</div>
|
||||||
|
{showTop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={scrollToTop}
|
||||||
|
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] j13-toast-in"
|
||||||
|
style={{ color: "var(--ink-2)" }}
|
||||||
|
aria-label="回到顶部"
|
||||||
|
>
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
|||||||
|
|
||||||
const actionText = (n: NotificationItem) => {
|
const actionText = (n: NotificationItem) => {
|
||||||
if (n.type === "comment") return "评论了你的帖子";
|
if (n.type === "comment") return "评论了你的帖子";
|
||||||
|
if (n.type === "reply") return "回复了你的评论";
|
||||||
return "点赞了你的帖子";
|
return "点赞了你的帖子";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -75,10 +75,7 @@ export default function PostRow({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{boardHref && (
|
{boardHref && (
|
||||||
<Link
|
<Link href={`/board/${post.board.id}`} className="j13-board-tag">
|
||||||
href={`/board/${post.board.id}`}
|
|
||||||
className={`av-${post.board.id % 8} rounded-md px-1.5 py-px text-[11px] font-bold leading-[1.5] transition-opacity hover:opacity-85`}
|
|
||||||
>
|
|
||||||
{post.board.name}
|
{post.board.name}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,100 +4,128 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Heart,
|
Heart,
|
||||||
MessageSquareText,
|
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Link2,
|
Link2,
|
||||||
|
Megaphone,
|
||||||
|
BarChart3,
|
||||||
|
PenLine,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
PostListItem,
|
PostListItem,
|
||||||
ActiveUser,
|
ActiveUser,
|
||||||
OverviewStats,
|
OverviewStats,
|
||||||
LatestComment,
|
AnnouncementSummary,
|
||||||
NewUser,
|
NewUser,
|
||||||
CheckinStatus,
|
CheckinStatus,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import CheckinCard from "./CheckinCard";
|
import CheckinCard from "./CheckinCard";
|
||||||
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatCompact, formatShortDate } from "@/lib/format";
|
||||||
|
|
||||||
/* ---------- 品牌卡 ---------- */
|
/* ---------- 站点数据(2×2 极简统计表) ---------- */
|
||||||
|
|
||||||
function BrandCard({ stats }: { stats: OverviewStats }) {
|
function StatCell({
|
||||||
const items: { num: number; label: string }[] = [
|
index,
|
||||||
{ num: stats.posts, label: "帖子" },
|
label,
|
||||||
{ num: stats.comments, label: "回复" },
|
value,
|
||||||
{ num: stats.users, label: "用户" },
|
foot,
|
||||||
];
|
}: {
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
foot: string;
|
||||||
|
}) {
|
||||||
|
const rightCol = index % 2 === 1;
|
||||||
|
const bottomRow = index >= 2;
|
||||||
return (
|
return (
|
||||||
<section className="j13-module text-center" aria-label="姜十三论坛">
|
|
||||||
<p className="text-[16.5px] font-extrabold tracking-wide" style={{ color: "var(--accent)" }}>
|
|
||||||
姜十三论坛
|
|
||||||
</p>
|
|
||||||
<p className="meta mt-1 text-[12px]">分享技术、讨论问题、交流心得</p>
|
|
||||||
<div
|
<div
|
||||||
className="mt-3.5 pt-3.5 grid grid-cols-3"
|
className="min-w-0 py-3.5"
|
||||||
style={{ borderTop: "1px solid var(--line)" }}
|
style={{
|
||||||
|
borderLeft: rightCol ? "1px solid var(--line)" : undefined,
|
||||||
|
borderTop: bottomRow ? "1px solid var(--line)" : undefined,
|
||||||
|
paddingLeft: rightCol ? 14 : 0,
|
||||||
|
paddingRight: rightCol ? 0 : 14,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{items.map((it) => (
|
<p className="text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||||
<div key={it.label}>
|
{label}
|
||||||
<p
|
</p>
|
||||||
className="text-[19px] font-extrabold tabular-nums leading-none"
|
<p
|
||||||
style={{ color: "var(--accent)" }}
|
className="mt-1.5 text-[20px] font-semibold leading-none tracking-tight tabular-nums"
|
||||||
>
|
style={{ color: "var(--ink)" }}
|
||||||
{it.num}
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[11px] tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{foot}
|
||||||
</p>
|
</p>
|
||||||
<p className="meta mt-1.5 text-[11.5px]">{it.label}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PulseCard({ stats }: { stats: OverviewStats }) {
|
||||||
|
return (
|
||||||
|
<section className="j13-module" aria-label="站点数据">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="j13-module-title">
|
||||||
|
<BarChart3 size={15} style={{ color: "var(--accent)" }} /> 站点数据
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-normal normal-case tracking-normal"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
title="近 5 分钟在线"
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "var(--ok)" }} />
|
||||||
|
实时
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2">
|
||||||
|
<StatCell index={0} label="注册用户" value={formatCompact(stats.users)} foot={`+${stats.today_users} 今日`} />
|
||||||
|
<StatCell index={1} label="话题总数" value={formatCompact(stats.posts)} foot={`+${stats.today_posts} 今日`} />
|
||||||
|
<StatCell index={2} label="回复总数" value={formatCompact(stats.comments)} foot={`+${stats.today_comments} 今日`} />
|
||||||
|
<StatCell index={3} label="在线用户" value={formatCompact(stats.online)} foot={`峰值 ${formatCompact(stats.peak_online)}`} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 最新评论 ---------- */
|
/* ---------- 站点公告 ---------- */
|
||||||
|
|
||||||
function LatestComments({ comments }: { comments: LatestComment[] }) {
|
function Announcements({ items }: { items: AnnouncementSummary[] }) {
|
||||||
if (comments.length === 0) return null;
|
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="最新评论">
|
<section className="j13-module" aria-label="站点公告">
|
||||||
<p className="j13-module-title mb-1">
|
<p className="j13-module-title mb-2">
|
||||||
<MessageSquareText size={15} style={{ color: "var(--accent)" }} /> 最新评论
|
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 站点公告
|
||||||
</p>
|
</p>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="meta px-1 py-1.5">暂无公告</p>
|
||||||
|
) : (
|
||||||
<ul>
|
<ul>
|
||||||
{comments.map((c) => {
|
{items.map((a) => (
|
||||||
const name = c.user.nickname || c.user.username;
|
<li key={a.id}>
|
||||||
return (
|
<Link
|
||||||
<li key={c.id}>
|
href={`/announcement/${a.id}`}
|
||||||
<Link href={`/post/${c.post.id}`} className="j13-raillink items-start">
|
className="group block -mx-1.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-[var(--panel-2)]"
|
||||||
<Avatar name={name} src={c.user.avatar} size={28} className="mt-px" />
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="flex items-baseline gap-1.5">
|
|
||||||
<span
|
|
||||||
className="shrink-0 max-w-[84px] truncate text-[12.5px] font-semibold"
|
|
||||||
style={{ color: "var(--ink-2)" }}
|
|
||||||
>
|
>
|
||||||
{name}
|
<span className="flex items-center justify-between gap-2">
|
||||||
|
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||||
|
<span className="meta shrink-0 text-[11px] tabular-nums">
|
||||||
|
{formatShortDate(a.created_at)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="min-w-0 flex-1 truncate text-[13px]"
|
className="mt-1.5 block truncate text-[13px] leading-snug transition-colors group-hover:text-[var(--accent)]"
|
||||||
style={{ color: "var(--ink-2)" }}
|
style={{ color: "var(--ink)" }}
|
||||||
|
title={a.title}
|
||||||
>
|
>
|
||||||
{c.content}
|
{a.title}
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="meta mt-0.5 block truncate text-[11.5px]">
|
|
||||||
{c.post.title}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="meta mt-px shrink-0 text-[11px]">
|
|
||||||
{formatRelative(c.created_at)}
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,11 +144,11 @@ function HotRanking({ posts }: { posts: PostListItem[] }) {
|
|||||||
<ol className="j13-ranklist">
|
<ol className="j13-ranklist">
|
||||||
{posts.map((p, i) => (
|
{posts.map((p, i) => (
|
||||||
<li key={p.id}>
|
<li key={p.id}>
|
||||||
<Link href={`/post/${p.id}`} className="j13-rank" title={p.title}>
|
<Link href={`/post/${p.id}`} className="j13-rank group" title={p.title}>
|
||||||
<span className="j13-rank-no">{i + 1}</span>
|
<span className="j13-rank-no">{i + 1}</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span
|
<span
|
||||||
className="block text-[13.5px] font-semibold leading-snug line-clamp-2"
|
className="block truncate text-[13px] font-normal leading-snug transition-colors group-hover:text-[var(--accent)]"
|
||||||
style={{ color: "var(--ink)" }}
|
style={{ color: "var(--ink)" }}
|
||||||
>
|
>
|
||||||
{p.title}
|
{p.title}
|
||||||
@@ -132,7 +160,6 @@ function HotRanking({ posts }: { posts: PostListItem[] }) {
|
|||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Heart size={11.5} /> {p.like_count}
|
<Heart size={11.5} /> {p.like_count}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{p.board.name}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -179,30 +206,56 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 活跃用户 ---------- */
|
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
|
||||||
|
|
||||||
function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="活跃成员">
|
<section className="j13-module" aria-label="本周活跃成员">
|
||||||
<p className="j13-module-title mb-3">
|
<p className="j13-module-title mb-2">
|
||||||
<Users size={15} style={{ color: "var(--accent)" }} /> 活跃成员
|
<Users size={15} style={{ color: "var(--accent)" }} /> 活跃成员
|
||||||
|
<span className="ml-auto font-normal normal-case tracking-normal text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
本周
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<p className="meta px-1.5 py-2">近 14 天还没有人互动</p>
|
<p className="meta px-1.5 py-2">本周还没有人互动</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-0.5">
|
||||||
{users.map((u) => (
|
{users.map((u) => {
|
||||||
|
const name = u.nickname || u.username;
|
||||||
|
return (
|
||||||
<li key={u.id}>
|
<li key={u.id}>
|
||||||
<Link href={`/u/${u.id}`} className="j13-raillink">
|
<Link href={`/u/${u.id}`} className="j13-raillink" title={name}>
|
||||||
<Avatar name={u.nickname || u.username} src={u.avatar} size={28} />
|
<Avatar name={name} src={u.avatar} size={30} />
|
||||||
<span className="flex-1 truncate text-[13.5px]">{u.nickname || u.username}</span>
|
<span className="flex-1 min-w-0">
|
||||||
<span className="meta text-[11.5px]">{u.activity_count} 次</span>
|
<span
|
||||||
|
className="block truncate text-[13px] font-semibold leading-tight"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span className="mt-px flex items-center gap-2.5 text-[11px] meta">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<PenLine size={10.5} /> 发帖 {u.post_count}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-[13px] font-extrabold tabular-nums"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
aria-label={`本周共 ${u.post_count + u.comment_count} 次互动`}
|
||||||
|
>
|
||||||
|
{u.post_count + u.comment_count}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<p className="meta mt-3 px-1 text-[11px]">按近 14 天发帖与评论计数</p>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -250,7 +303,7 @@ type PanelsProps = {
|
|||||||
stats: OverviewStats;
|
stats: OverviewStats;
|
||||||
hot: PostListItem[];
|
hot: PostListItem[];
|
||||||
activeUsers: ActiveUser[];
|
activeUsers: ActiveUser[];
|
||||||
latestComments: LatestComment[];
|
announcements: AnnouncementSummary[];
|
||||||
newUsers: NewUser[];
|
newUsers: NewUser[];
|
||||||
checkin?: CheckinStatus | null;
|
checkin?: CheckinStatus | null;
|
||||||
};
|
};
|
||||||
@@ -259,16 +312,16 @@ export function CommunityPanels({
|
|||||||
stats,
|
stats,
|
||||||
hot,
|
hot,
|
||||||
activeUsers,
|
activeUsers,
|
||||||
latestComments,
|
announcements,
|
||||||
newUsers,
|
newUsers,
|
||||||
checkin,
|
checkin,
|
||||||
}: PanelsProps) {
|
}: PanelsProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
|
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
|
||||||
<div className="sticky top-24 space-y-4">
|
<div className="sticky top-24 space-y-4">
|
||||||
<BrandCard stats={stats} />
|
<PulseCard stats={stats} />
|
||||||
|
<Announcements items={announcements} />
|
||||||
<CheckinCard initial={checkin} />
|
<CheckinCard initial={checkin} />
|
||||||
<LatestComments comments={latestComments} />
|
|
||||||
<HotRanking posts={hot} />
|
<HotRanking posts={hot} />
|
||||||
<NewUsers users={newUsers} />
|
<NewUsers users={newUsers} />
|
||||||
<ActiveUsers users={activeUsers} />
|
<ActiveUsers users={activeUsers} />
|
||||||
@@ -281,14 +334,14 @@ export function CommunityPanels({
|
|||||||
export function CommunityPanelsMobile({
|
export function CommunityPanelsMobile({
|
||||||
hot,
|
hot,
|
||||||
activeUsers,
|
activeUsers,
|
||||||
latestComments,
|
announcements,
|
||||||
newUsers,
|
newUsers,
|
||||||
checkin,
|
checkin,
|
||||||
}: Omit<PanelsProps, "stats">) {
|
}: Omit<PanelsProps, "stats">) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 lg:hidden" aria-label="社区面板">
|
<div className="space-y-4 lg:hidden" aria-label="社区面板">
|
||||||
|
<Announcements items={announcements} />
|
||||||
<CheckinCard initial={checkin} />
|
<CheckinCard initial={checkin} />
|
||||||
<LatestComments comments={latestComments} />
|
|
||||||
<HotRanking posts={hot} />
|
<HotRanking posts={hot} />
|
||||||
<NewUsers users={newUsers} />
|
<NewUsers users={newUsers} />
|
||||||
<ActiveUsers users={activeUsers} />
|
<ActiveUsers users={activeUsers} />
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export interface OverviewStats {
|
|||||||
users: number;
|
users: number;
|
||||||
comments: number;
|
comments: number;
|
||||||
today_posts: number;
|
today_posts: number;
|
||||||
|
today_users: number;
|
||||||
|
today_comments: number;
|
||||||
|
online: number;
|
||||||
|
peak_online: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveUser {
|
export interface ActiveUser {
|
||||||
@@ -56,19 +60,42 @@ export interface ActiveUser {
|
|||||||
username: string;
|
username: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
activity_count: number;
|
post_count: number;
|
||||||
|
comment_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardWithCount extends Board {
|
export interface BoardWithCount extends Board {
|
||||||
post_count: number;
|
post_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LatestComment {
|
// 公告标签预设色(与后端白名单一致)
|
||||||
|
export type AnnouncementColor =
|
||||||
|
| "blue"
|
||||||
|
| "green"
|
||||||
|
| "orange"
|
||||||
|
| "red"
|
||||||
|
| "purple"
|
||||||
|
| "gray";
|
||||||
|
|
||||||
|
// 右栏站点公告摘要(标签/日期/标题)
|
||||||
|
export interface AnnouncementSummary {
|
||||||
id: number;
|
id: number;
|
||||||
content: string;
|
title: string;
|
||||||
|
tag: string;
|
||||||
|
tag_color: AnnouncementColor;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
user: { id: number; username: string; nickname: string; avatar: string };
|
}
|
||||||
post: { id: number; title: string };
|
|
||||||
|
// 公告详情/后台管理完整对象
|
||||||
|
export interface Announcement {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
tag: string;
|
||||||
|
tag_color: AnnouncementColor;
|
||||||
|
published: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewUser {
|
export interface NewUser {
|
||||||
@@ -91,7 +118,7 @@ export interface OverviewResponse {
|
|||||||
hot: PostListItem[];
|
hot: PostListItem[];
|
||||||
active_users: ActiveUser[];
|
active_users: ActiveUser[];
|
||||||
boards: BoardWithCount[];
|
boards: BoardWithCount[];
|
||||||
latest_comments: LatestComment[];
|
announcements: AnnouncementSummary[];
|
||||||
new_users: NewUser[];
|
new_users: NewUser[];
|
||||||
checkin?: CheckinStatus | null;
|
checkin?: CheckinStatus | null;
|
||||||
}
|
}
|
||||||
@@ -143,12 +170,29 @@ export interface Comment {
|
|||||||
id: number;
|
id: number;
|
||||||
post_id: number;
|
post_id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
|
parent_id?: number | null; // 父评论 ID,null = 主评论(楼层)
|
||||||
|
root_id?: number | null; // 所属楼层 ID,子评论必填
|
||||||
|
depth?: number; // 层级:主评论 0,子评论 = 父 + 1
|
||||||
content: string;
|
content: string;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 评论树节点:主评论为根,replies 为其全部子回复(时间正序,全量 SSR 直出)
|
||||||
|
export interface CommentNode extends Comment {
|
||||||
|
replies?: CommentNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 帖子评论楼层分页响应(时间正序;楼层号 = (page-1)*size + 序号 + 1,仅主评论占楼层)
|
||||||
|
export interface CommentsResponse {
|
||||||
|
comments: CommentNode[];
|
||||||
|
total: number; // 楼层数(主评论数)→ 分页与楼层号计算
|
||||||
|
total_comments: number; // 全部评论数(含回复)→ 头部徽标
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PostsResponse {
|
export interface PostsResponse {
|
||||||
posts: PostListItem[];
|
posts: PostListItem[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -168,6 +212,8 @@ export interface UserProfile {
|
|||||||
stats: {
|
stats: {
|
||||||
post_count: number;
|
post_count: number;
|
||||||
comment_count: number;
|
comment_count: number;
|
||||||
|
points: number;
|
||||||
|
streak: number;
|
||||||
};
|
};
|
||||||
posts: PostListItem[];
|
posts: PostListItem[];
|
||||||
posts_total: number;
|
posts_total: number;
|
||||||
@@ -194,7 +240,7 @@ export interface NotificationItem {
|
|||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
actor_id: number;
|
actor_id: number;
|
||||||
type: "comment" | "like";
|
type: "comment" | "reply" | "like";
|
||||||
post_id: number;
|
post_id: number;
|
||||||
comment_id: number;
|
comment_id: number;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -311,8 +357,9 @@ export async function fetchPostDetail(id: string, cookieHeader?: string): Promis
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchComments(postId: string): Promise<{ comments: Comment[] }> {
|
export async function fetchComments(postId: string, page = 1): Promise<CommentsResponse> {
|
||||||
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments`, ssrInit());
|
const params = new URLSearchParams({ page: String(page), size: "20" });
|
||||||
|
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments?${params}`, ssrInit());
|
||||||
if (!res.ok) throw new Error("获取评论失败");
|
if (!res.ok) throw new Error("获取评论失败");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -346,6 +393,15 @@ export async function fetchUserComments(id: string, page = 1): Promise<UserComme
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSR 直出通知列表(仅登录用户;401 由页面层转为登录引导)
|
||||||
|
export async function fetchNotifications(page = 1, cookieHeader?: string): Promise<NotificationsResponse> {
|
||||||
|
const params = new URLSearchParams({ page: String(page), size: "20" });
|
||||||
|
const res = await fetch(`${API_BASE}/api/notifications?${params}`, ssrInit(cookieHeaders(cookieHeader)));
|
||||||
|
if (res.status === 401) throw new Error("未登录");
|
||||||
|
if (!res.ok) throw new Error("获取通知失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) =====
|
// ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) =====
|
||||||
|
|
||||||
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 })
|
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 })
|
||||||
@@ -400,11 +456,11 @@ export async function apiCreatePost(data: {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiCreateComment(postId: string, content: string) {
|
export async function apiCreateComment(postId: string, content: string, parentId?: number) {
|
||||||
const res = await fetchWithRefresh(`/api/posts/${postId}/comments`, {
|
const res = await fetchWithRefresh(`/api/posts/${postId}/comments`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify(parentId ? { content, parent_id: parentId } : { content }),
|
||||||
});
|
});
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -522,3 +578,69 @@ export async function apiDoCheckin(): Promise<CheckinStatus> {
|
|||||||
if (!res.ok) throw new Error(data.error || "签到失败");
|
if (!res.ok) throw new Error(data.error || "签到失败");
|
||||||
return data as CheckinStatus;
|
return data as CheckinStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 站点公告 =====
|
||||||
|
|
||||||
|
// SSR 公告详情(仅已发布;草稿/不存在由调用方按 404 处理)
|
||||||
|
export async function fetchAnnouncementDetail(
|
||||||
|
id: string
|
||||||
|
): Promise<{ announcement: Announcement } | null> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/announcements/${id}`, ssrInit());
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) throw new Error("获取公告失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台:公告列表(含草稿)
|
||||||
|
export async function apiAdminListAnnouncements(): Promise<{ announcements: Announcement[] }> {
|
||||||
|
const res = await fetchWithRefresh("/api/admin/announcements", {
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "获取公告列表失败");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementInput {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
tag: string;
|
||||||
|
tag_color: AnnouncementColor;
|
||||||
|
published: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAnnouncement(
|
||||||
|
method: "POST" | "PUT",
|
||||||
|
url: string,
|
||||||
|
input: AnnouncementInput
|
||||||
|
): Promise<{ announcement: Announcement }> {
|
||||||
|
const res = await fetchWithRefresh(url, {
|
||||||
|
method,
|
||||||
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || "保存公告失败");
|
||||||
|
return data as { announcement: Announcement };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiAdminCreateAnnouncement(input: AnnouncementInput) {
|
||||||
|
return saveAnnouncement("POST", "/api/admin/announcements", input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiAdminUpdateAnnouncement(id: number, input: AnnouncementInput) {
|
||||||
|
return saveAnnouncement("PUT", `/api/admin/announcements/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
|
||||||
|
const res = await fetchWithRefresh(`/api/admin/announcements/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "删除公告失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,3 +12,38 @@ export function formatRelative(dateStr: string): string {
|
|||||||
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月前`;
|
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月前`;
|
||||||
return `${Math.floor(diff / (365 * day))}年前`;
|
return `${Math.floor(diff / (365 * day))}年前`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 去掉末尾多余的 .0(12.0 → 12,1.25 → 1.3 由 toFixed 保证) */
|
||||||
|
function trimTenths(v: number): string {
|
||||||
|
return String(Number(v.toFixed(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 紧凑数字:999 / 1.2K / 128K / 2.3M(站点数据卡) */
|
||||||
|
export function formatCompact(n: number): string {
|
||||||
|
if (!Number.isFinite(n)) return "0";
|
||||||
|
if (n < 1000) return String(Math.round(n));
|
||||||
|
if (n < 1_000_000) {
|
||||||
|
const v = n / 1000;
|
||||||
|
return `${v >= 100 ? Math.round(v) : trimTenths(v)}K`;
|
||||||
|
}
|
||||||
|
return `${trimTenths(n / 1_000_000)}M`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n: number) {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 短日期:今年显示 MM-DD,往年显示 YYYY-MM-DD(右栏公告用) */
|
||||||
|
export function formatShortDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||||
|
return d.getFullYear() === new Date().getFullYear()
|
||||||
|
? md
|
||||||
|
: `${d.getFullYear()}-${md}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整日期 YYYY-MM-DD */
|
||||||
|
export function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { fetchMe } from "./api";
|
import { fetchBoards, fetchMe } 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 每请求调用,失败降级为空列表(导航隐藏,不阻塞页面)
|
||||||
|
export const getBoardsCached = cache(async () => {
|
||||||
|
try {
|
||||||
|
const { boards } = await fetchBoards();
|
||||||
|
return boards;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
import "./.next/types/root-params.d.ts";
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
Reference in New Issue
Block a user