diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 8714f12..8e2945a 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -33,6 +33,7 @@ func main() { authHandler := handlers.NewAuthHandler(jwtManager) commentHandler := handlers.NewCommentHandler() likeHandler := handlers.NewLikeHandler() + microHandler := handlers.NewMicroHandler() // 创建路由 r := gin.New() @@ -70,6 +71,12 @@ func main() { api.GET("/likes", likeHandler.GetLikeStatus) api.POST("/likes", likeHandler.ToggleLike) // 允许访客点赞(基于 IP Hash) + // 微语公开接口 + api.GET("/micros", microHandler.GetMicros) + api.GET("/micros/stats", microHandler.GetStats) + api.GET("/micros/heatmap", microHandler.GetHeatmap) + api.GET("/micros/:id", microHandler.GetMicro) + // 需要认证的接口 authGroup := api.Group("") authGroup.Use(middleware.AuthMiddleware(jwtManager)) @@ -81,6 +88,12 @@ func main() { // 评论相关(需要登录才能评论) authGroup.POST("/comments", commentHandler.CreateComment) authGroup.DELETE("/comments/:id", commentHandler.DeleteComment) + + // 微语相关(需要登录) + authGroup.POST("/micros", microHandler.CreateMicro) + authGroup.PUT("/micros/:id", microHandler.UpdateMicro) + authGroup.DELETE("/micros/:id", microHandler.DeleteMicro) + authGroup.POST("/micros/:id/like", microHandler.ToggleLike) } // 管理员接口 diff --git a/server/internal/database/database.go b/server/internal/database/database.go index c7da001..0491d04 100644 --- a/server/internal/database/database.go +++ b/server/internal/database/database.go @@ -54,6 +54,8 @@ func autoMigrate() error { &models.Like{}, &models.LikeCount{}, &models.PostMeta{}, + &models.MicroPost{}, + &models.MicroPostLike{}, ) } diff --git a/server/internal/handlers/micro.go b/server/internal/handlers/micro.go new file mode 100644 index 0000000..7832a40 --- /dev/null +++ b/server/internal/handlers/micro.go @@ -0,0 +1,411 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/novablog/server/internal/database" + "github.com/novablog/server/internal/middleware" + "github.com/novablog/server/internal/models" + "gorm.io/gorm" +) + +func mustMarshal(v interface{}) []byte { + data, _ := json.Marshal(v) + return data +} + +type MicroHandler struct{} + +func NewMicroHandler() *MicroHandler { + return &MicroHandler{} +} + +type CreateMicroRequest struct { + Content string `json:"content" binding:"required,max=2000"` + Images []string `json:"images"` + Tags []string `json:"tags"` + IsPublic bool `json:"is_public"` +} + +type UpdateMicroRequest struct { + Content string `json:"content" binding:"required,max=2000"` + Images []string `json:"images"` + Tags []string `json:"tags"` + IsPublic bool `json:"is_public"` +} + +type MicroResponse struct { + models.MicroPost + LikeCount int `json:"like_count"` + IsLiked bool `json:"is_liked"` +} + +func (h *MicroHandler) CreateMicro(c *gin.Context) { + userID, ok := middleware.GetUserID(c) + if !ok || userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后再发布"}) + return + } + + var req CreateMicroRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + imagesJSON := "[]" + if len(req.Images) > 0 { + imagesJSON = string(mustMarshal(req.Images)) + } + + tagsJSON := "[]" + if len(req.Tags) > 0 { + tagsJSON = string(mustMarshal(req.Tags)) + } + + micro := models.MicroPost{ + UserID: userID, + Content: req.Content, + Images: imagesJSON, + Tags: tagsJSON, + IsPublic: req.IsPublic, + } + + if err := database.DB.Create(µ).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败"}) + return + } + + database.DB.Preload("User").First(µ, micro.ID) + + c.JSON(http.StatusCreated, MicroResponse{ + MicroPost: micro, + LikeCount: 0, + IsLiked: false, + }) +} + +func (h *MicroHandler) GetMicros(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + userIDQuery := c.Query("user_id") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 50 { + pageSize = 20 + } + + var micros []models.MicroPost + var total int64 + + query := database.DB.Model(&models.MicroPost{}).Where("is_public = ?", true) + + if userIDQuery != "" { + query = query.Where("user_id = ?", userIDQuery) + } + + query.Count(&total) + + if err := query. + Preload("User"). + Order("created_at DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(µs).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取失败"}) + return + } + + currentUserID, _ := middleware.GetUserID(c) + + responses := make([]MicroResponse, len(micros)) + for i, micro := range micros { + var likeCount int64 + database.DB.Model(&models.MicroPostLike{}).Where("micro_post_id = ?", micro.ID).Count(&likeCount) + + isLiked := false + if currentUserID > 0 { + var count int64 + database.DB.Model(&models.MicroPostLike{}). + Where("micro_post_id = ? AND user_id = ?", micro.ID, currentUserID). + Count(&count) + isLiked = count > 0 + } + + responses[i] = MicroResponse{ + MicroPost: micro, + LikeCount: int(likeCount), + IsLiked: isLiked, + } + } + + c.JSON(http.StatusOK, gin.H{ + "data": responses, + "pagination": gin.H{ + "page": page, + "page_size": pageSize, + "total": total, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }, + }) +} + +func (h *MicroHandler) GetMicro(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var micro models.MicroPost + if err := database.DB.Preload("User").First(µ, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + var likeCount int64 + database.DB.Model(&models.MicroPostLike{}).Where("micro_post_id = ?", micro.ID).Count(&likeCount) + + currentUserID, _ := middleware.GetUserID(c) + isLiked := false + if currentUserID > 0 { + var count int64 + database.DB.Model(&models.MicroPostLike{}). + Where("micro_post_id = ? AND user_id = ?", micro.ID, currentUserID). + Count(&count) + isLiked = count > 0 + } + + c.JSON(http.StatusOK, MicroResponse{ + MicroPost: micro, + LikeCount: int(likeCount), + IsLiked: isLiked, + }) +} + +func (h *MicroHandler) UpdateMicro(c *gin.Context) { + userID, _ := middleware.GetUserID(c) + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var micro models.MicroPost + if err := database.DB.First(µ, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + if micro.UserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "无权修改"}) + return + } + + var req UpdateMicroRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + imagesJSON := "[]" + if len(req.Images) > 0 { + imagesJSON = string(mustMarshal(req.Images)) + } + + tagsJSON := "[]" + if len(req.Tags) > 0 { + tagsJSON = string(mustMarshal(req.Tags)) + } + + updates := map[string]interface{}{ + "content": req.Content, + "images": imagesJSON, + "tags": tagsJSON, + "is_public": req.IsPublic, + } + + if err := database.DB.Model(µ).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"}) + return + } + + database.DB.Preload("User").First(µ, micro.ID) + + c.JSON(http.StatusOK, micro) +} + +func (h *MicroHandler) DeleteMicro(c *gin.Context) { + userID, _ := middleware.GetUserID(c) + role, _ := c.Get("role") + + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var micro models.MicroPost + if err := database.DB.First(µ, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + if micro.UserID != userID && role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "无权删除"}) + return + } + + if err := database.DB.Delete(µ).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} + +func (h *MicroHandler) ToggleLike(c *gin.Context) { + userID, ok := middleware.GetUserID(c) + if !ok || userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后再点赞"}) + return + } + + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var micro models.MicroPost + if err := database.DB.First(µ, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + var existingLike models.MicroPostLike + result := database.DB.Where("micro_post_id = ? AND user_id = ?", id, userID).First(&existingLike) + + if result.Error == nil { + database.DB.Delete(&existingLike) + c.JSON(http.StatusOK, gin.H{"liked": false, "message": "取消点赞"}) + return + } + + like := models.MicroPostLike{ + MicroPostID: uint(id), + UserID: userID, + } + + if err := database.DB.Create(&like).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "点赞失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"liked": true, "message": "点赞成功"}) +} + +func (h *MicroHandler) GetHeatmap(c *gin.Context) { + userIDQuery := c.Query("user_id") + yearStr := c.DefaultQuery("year", strconv.Itoa(time.Now().Year())) + year, _ := strconv.Atoi(yearStr) + + query := database.DB.Model(&models.MicroPost{}). + Select("DATE(created_at) as date, COUNT(*) as count"). + Where("is_public = ?", true). + Group("DATE(created_at)") + + if userIDQuery != "" { + query = query.Where("user_id = ?", userIDQuery) + } + + if year > 0 { + startDate := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) + endDate := time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC) + query = query.Where("created_at >= ? AND created_at < ?", startDate, endDate) + } + + var results []struct { + Date string `json:"date"` + Count int `json:"count"` + } + + if err := query.Scan(&results).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取失败"}) + return + } + + c.JSON(http.StatusOK, results) +} + +func (h *MicroHandler) GetStats(c *gin.Context) { + userIDQuery := c.Query("user_id") + + var totalMicros int64 + var totalUsers int64 + + query := database.DB.Model(&models.MicroPost{}).Where("is_public = ?", true) + if userIDQuery != "" { + query = query.Where("user_id = ?", userIDQuery) + } + query.Count(&totalMicros) + + database.DB.Model(&models.User{}).Count(&totalUsers) + + var topUsers []struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + PostCount int `json:"post_count"` + } + + database.DB.Model(&models.MicroPost{}). + Select("user_id, COUNT(*) as post_count"). + Where("is_public = ?", true). + Group("user_id"). + Order("post_count DESC"). + Limit(10). + Scan(&topUsers) + + for i := range topUsers { + var user models.User + if err := database.DB.First(&user, topUsers[i].UserID).Error; err == nil { + topUsers[i].Username = user.Username + topUsers[i].Nickname = user.Nickname + topUsers[i].Avatar = user.Avatar + } + } + + c.JSON(http.StatusOK, gin.H{ + "total_micros": totalMicros, + "total_users": totalUsers, + "top_users": topUsers, + }) +} diff --git a/server/internal/models/models.go b/server/internal/models/models.go index 8f031f0..1ddd513 100644 --- a/server/internal/models/models.go +++ b/server/internal/models/models.go @@ -61,4 +61,26 @@ type PostMeta struct { LikeCount int `json:"like_count" gorm:"default:0"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` +} + +// MicroPost 微语模型 +type MicroPost struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + UserID uint `json:"user_id" gorm:"index;not null"` + Content string `json:"content" gorm:"type:text;not null"` + Images string `json:"images" gorm:"type:text"` // JSON 数组存储图片 URL + Tags string `json:"tags" gorm:"type:text"` // JSON 数组存储标签 + IsPublic bool `json:"is_public" gorm:"default:true"` // 是否公开 + User User `json:"user" gorm:"foreignKey:UserID"` +} + +// MicroPostLike 微语点赞 +type MicroPostLike struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + MicroPostID uint `json:"micro_post_id" gorm:"uniqueIndex:idx_micropost_user;not null"` + UserID uint `json:"user_id" gorm:"uniqueIndex:idx_micropost_user;not null"` } \ No newline at end of file diff --git a/server/novablog-server b/server/novablog-server index a34fcc2..1439741 100755 Binary files a/server/novablog-server and b/server/novablog-server differ diff --git a/src/components/react/Heatmap.tsx b/src/components/react/Heatmap.tsx new file mode 100644 index 0000000..ff92147 --- /dev/null +++ b/src/components/react/Heatmap.tsx @@ -0,0 +1,185 @@ +import { useState, useEffect } from 'react'; + +interface HeatmapData { + date: string; + count: number; +} + +interface HeatmapProps { + userId?: string; + year?: number; + apiBaseUrl?: string; +} + +const API_BASE = typeof window !== 'undefined' + ? (import.meta.env.VITE_API_BASE || 'http://localhost:8080/api') + : 'http://localhost:8080/api'; + +const COLORS = [ + 'bg-primary-100 dark:bg-primary-900/30', + 'bg-primary-200 dark:bg-primary-800/40', + 'bg-primary-300 dark:bg-primary-700/50', + 'bg-primary-400 dark:bg-primary-600/60', + 'bg-primary-500 dark:bg-primary-500/70', +]; + +const MONTHS = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; +const DAYS = ['日', '一', '二', '三', '四', '五', '六']; + +export default function Heatmap({ userId, year = new Date().getFullYear(), apiBaseUrl }: HeatmapProps) { + const baseUrl = apiBaseUrl || API_BASE; + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [hoveredCell, setHoveredCell] = useState<{ date: string; count: number } | null>(null); + + useEffect(() => { + const fetchHeatmap = async () => { + try { + const params = new URLSearchParams({ year: year.toString() }); + if (userId) params.append('user_id', userId); + + const response = await fetch(`${baseUrl}/micros/heatmap?${params}`); + if (response.ok) { + const result = await response.json(); + setData(result); + } + } catch (error) { + console.error('Failed to fetch heatmap:', error); + } finally { + setLoading(false); + } + }; + + fetchHeatmap(); + }, [userId, year, baseUrl]); + + const getDaysInYear = (year: number) => { + const days: Date[] = []; + const startDate = new Date(year, 0, 1); + const endDate = new Date(year, 11, 31); + + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + days.push(new Date(d)); + } + return days; + }; + + const getCountForDate = (date: Date): number => { + const dateStr = date.toISOString().split('T')[0]; + const item = data.find(d => d.date === dateStr); + return item ? item.count : 0; + }; + + const getColorClass = (count: number): string => { + if (count === 0) return 'bg-muted dark:bg-muted/50'; + if (count <= 2) return COLORS[0]; + if (count <= 4) return COLORS[1]; + if (count <= 6) return COLORS[2]; + if (count <= 8) return COLORS[3]; + return COLORS[4]; + }; + + const formatDisplayDate = (date: Date): string => { + return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`; + }; + + const getWeeksInYear = (year: number) => { + const days = getDaysInYear(year); + const weeks: Date[][] = []; + let currentWeek: Date[] = []; + + const firstDay = days[0]; + const firstDayOfWeek = firstDay.getDay(); + for (let i = 0; i < firstDayOfWeek; i++) { + currentWeek.push(new Date(year, 0, 1 - firstDayOfWeek + i)); + } + + days.forEach(day => { + if (day.getDay() === 0 && currentWeek.length > 0) { + weeks.push(currentWeek); + currentWeek = []; + } + currentWeek.push(day); + }); + + if (currentWeek.length > 0) { + weeks.push(currentWeek); + } + + return weeks; + }; + + const weeks = getWeeksInYear(year); + const totalCount = data.reduce((sum, item) => sum + item.count, 0); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

{year} 年微语热力图

+ 共 {totalCount} 条 +
+
+ + {COLORS.map((color, i) => ( +
+ ))} + +
+
+ +
+
+
+ {DAYS.map((day, i) => ( +
{i % 2 === 1 ? day : ''}
+ ))} +
+ + {weeks.map((week, weekIndex) => ( +
+ {DAYS.map((_, dayIndex) => { + const day = week[dayIndex]; + if (!day || day.getFullYear() !== year) { + return
; + } + + const count = getCountForDate(day); + const colorClass = getColorClass(count); + + return ( +
setHoveredCell({ date: formatDisplayDate(day), count })} + onMouseLeave={() => setHoveredCell(null)} + /> + ); + })} +
+ ))} +
+
+ + {hoveredCell && ( +
+ {hoveredCell.date}:{hoveredCell.count} 条微语 +
+ )} + +
+ {MONTHS.map((month, i) => ( + {month} + ))} +
+
+ ); +} diff --git a/src/components/react/MicroComposer.tsx b/src/components/react/MicroComposer.tsx new file mode 100644 index 0000000..cf3968b --- /dev/null +++ b/src/components/react/MicroComposer.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react'; + +interface MicroComposerProps { + onClose?: () => void; + onSuccess?: () => void; + apiBaseUrl?: string; +} + +const API_BASE = typeof window !== 'undefined' + ? (import.meta.env.VITE_API_BASE || 'http://localhost:8080/api') + : 'http://localhost:8080/api'; + +export default function MicroComposer({ onClose, onSuccess, apiBaseUrl }: MicroComposerProps) { + const baseUrl = apiBaseUrl || API_BASE; + const [content, setContent] = useState(''); + const [tags, setTags] = useState(''); + const [isPublic, setIsPublic] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + if (!content.trim()) { + alert('请输入内容'); + return; + } + + const token = localStorage.getItem('token'); + if (!token) { + alert('请登录后再发布'); + return; + } + + setSubmitting(true); + try { + const tagList = tags + .split(/[,,\s]+/) + .map(t => t.trim()) + .filter(t => t.length > 0); + + const response = await fetch(`${baseUrl}/micros`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + content: content.trim(), + tags: tagList, + is_public: isPublic, + images: [], + }), + }); + + if (response.ok) { + setContent(''); + setTags(''); + setIsPublic(true); + onSuccess?.(); + onClose?.(); + } else { + const error = await response.json(); + alert(error.error || '发布失败'); + } + } catch (error) { + console.error('Failed to post:', error); + alert('发布失败,请重试'); + } finally { + setSubmitting(false); + } + }; + + const remainingChars = 2000 - content.length; + + return ( +
+
+