update mico module
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
// 管理员接口
|
||||
|
||||
@@ -54,6 +54,8 @@ func autoMigrate() error {
|
||||
&models.Like{},
|
||||
&models.LikeCount{},
|
||||
&models.PostMeta{},
|
||||
&models.MicroPost{},
|
||||
&models.MicroPostLike{},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
411
server/internal/handlers/micro.go
Normal file
411
server/internal/handlers/micro.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Binary file not shown.
185
src/components/react/Heatmap.tsx
Normal file
185
src/components/react/Heatmap.tsx
Normal file
@@ -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<HeatmapData[]>([]);
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">{year} 年微语热力图</h3>
|
||||
<span className="text-sm text-muted-foreground">共 {totalCount} 条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>少</span>
|
||||
{COLORS.map((color, i) => (
|
||||
<div key={i} className={`w-3 h-3 rounded-sm ${color}`}></div>
|
||||
))}
|
||||
<span>多</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-flex gap-1">
|
||||
<div className="flex flex-col gap-1 mr-2 text-xs text-muted-foreground">
|
||||
{DAYS.map((day, i) => (
|
||||
<div key={i} className="h-3 flex items-center">{i % 2 === 1 ? day : ''}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-1">
|
||||
{DAYS.map((_, dayIndex) => {
|
||||
const day = week[dayIndex];
|
||||
if (!day || day.getFullYear() !== year) {
|
||||
return <div key={dayIndex} className="w-3 h-3"></div>;
|
||||
}
|
||||
|
||||
const count = getCountForDate(day);
|
||||
const colorClass = getColorClass(count);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className={`w-3 h-3 rounded-sm ${colorClass} cursor-pointer transition-transform hover:scale-125`}
|
||||
onMouseEnter={() => setHoveredCell({ date: formatDisplayDate(day), count })}
|
||||
onMouseLeave={() => setHoveredCell(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hoveredCell && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{hoveredCell.date}:{hoveredCell.count} 条微语
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
{MONTHS.map((month, i) => (
|
||||
<span key={i} className="flex-1 text-center">{month}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/components/react/MicroComposer.tsx
Normal file
132
src/components/react/MicroComposer.tsx
Normal file
@@ -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 (
|
||||
<div className="card">
|
||||
<div className="mb-4">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="分享你的想法..."
|
||||
className="input min-h-[120px] resize-none"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<div className="flex justify-end mt-1">
|
||||
<span className={`text-xs ${remainingChars < 100 ? 'text-red-500' : 'text-muted-foreground'}`}>
|
||||
{remainingChars} 字
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="标签(用逗号或空格分隔)"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">公开可见</span>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn-secondary"
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="btn-primary"
|
||||
disabled={submitting || !content.trim()}
|
||||
>
|
||||
{submitting ? '发布中...' : '发布'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
280
src/components/react/MicroList.tsx
Normal file
280
src/components/react/MicroList.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface MicroPost {
|
||||
id: number;
|
||||
content: string;
|
||||
images: string;
|
||||
tags: string;
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user: User;
|
||||
like_count: number;
|
||||
is_liked: boolean;
|
||||
}
|
||||
|
||||
interface MicroListProps {
|
||||
userId?: string;
|
||||
onOpenComposer?: () => 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 MicroList({ userId, onOpenComposer, apiBaseUrl }: MicroListProps) {
|
||||
const baseUrl = apiBaseUrl || API_BASE;
|
||||
const [micros, setMicros] = useState<MicroPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const fetchMicros = useCallback(async (pageNum: number, append = false) => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: pageNum.toString(),
|
||||
page_size: '10',
|
||||
});
|
||||
if (userId) params.append('user_id', userId);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/micros?${params}`, { headers });
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (append) {
|
||||
setMicros(prev => [...prev, ...result.data]);
|
||||
} else {
|
||||
setMicros(result.data);
|
||||
}
|
||||
setTotal(result.pagination.total);
|
||||
setHasMore(result.pagination.page < result.pagination.total_page);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch micros:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId, baseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMicros(1);
|
||||
}, [fetchMicros]);
|
||||
|
||||
const loadMore = () => {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
fetchMicros(nextPage, true);
|
||||
};
|
||||
|
||||
const handleLike = async (microId: number) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
alert('请登录后再点赞');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/micros/${microId}/like`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setMicros(prev => prev.map(m => {
|
||||
if (m.id === microId) {
|
||||
return {
|
||||
...m,
|
||||
is_liked: result.liked,
|
||||
like_count: result.liked ? m.like_count + 1 : m.like_count - 1,
|
||||
};
|
||||
}
|
||||
return m;
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to like:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
if (days < 7) return `${days} 天前`;
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const parseJSON = (str: string) => {
|
||||
try {
|
||||
return JSON.parse(str || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-foreground">微语</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">共 {total} 条</span>
|
||||
{onOpenComposer && (
|
||||
<button
|
||||
onClick={onOpenComposer}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
发布微语
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{micros.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
暂无微语,来发布第一条吧!
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{micros.map(micro => {
|
||||
const images = parseJSON(micro.images);
|
||||
const tags = parseJSON(micro.tags);
|
||||
|
||||
return (
|
||||
<div key={micro.id} className="card">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
{micro.user.avatar ? (
|
||||
<img
|
||||
src={micro.user.avatar}
|
||||
alt={micro.user.nickname || micro.user.username}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-primary-500 flex items-center justify-center text-white font-semibold">
|
||||
{(micro.user.nickname || micro.user.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-foreground">
|
||||
{micro.user.nickname || micro.user.username}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@{micro.user.username}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(micro.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-foreground whitespace-pre-wrap break-words mb-3">
|
||||
{micro.content}
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className={`grid gap-2 mb-3 ${
|
||||
images.length === 1 ? 'grid-cols-1' :
|
||||
images.length === 2 ? 'grid-cols-2' :
|
||||
'grid-cols-3'
|
||||
}`}>
|
||||
{images.map((img: string, i: number) => (
|
||||
<img
|
||||
key={i}
|
||||
src={img}
|
||||
alt={`图片 ${i + 1}`}
|
||||
className="rounded-lg object-cover w-full aspect-square"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{tags.map((tag: string, i: number) => (
|
||||
<span key={i} className="tag text-xs">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-6 text-muted-foreground">
|
||||
<button
|
||||
onClick={() => handleLike(micro.id)}
|
||||
className={`flex items-center gap-1 transition-colors ${
|
||||
micro.is_liked ? 'text-red-500' : 'hover:text-red-500'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill={micro.is_liked ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">{micro.like_count || ''}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="text-center pt-4">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="btn-secondary"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/components/react/MicroPage.tsx
Normal file
59
src/components/react/MicroPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import MicroList from './MicroList';
|
||||
import MicroComposer from './MicroComposer';
|
||||
import Heatmap from './Heatmap';
|
||||
|
||||
interface MicroPageProps {
|
||||
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 MicroPage({ apiBaseUrl }: MicroPageProps) {
|
||||
const baseUrl = apiBaseUrl || API_BASE;
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [heatmapKey, setHeatmapKey] = useState(0);
|
||||
|
||||
const handlePostSuccess = useCallback(() => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
setHeatmapKey(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||
<div className="xl:col-span-2">
|
||||
<div className="mb-8">
|
||||
<MicroComposer
|
||||
apiBaseUrl={baseUrl}
|
||||
onSuccess={handlePostSuccess}
|
||||
/>
|
||||
</div>
|
||||
<MicroList
|
||||
key={refreshKey}
|
||||
apiBaseUrl={baseUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-1">
|
||||
<div className="sticky top-24 space-y-6">
|
||||
<div className="card">
|
||||
<Heatmap
|
||||
key={heatmapKey}
|
||||
apiBaseUrl={baseUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">关于微语</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
微语是一个轻量级的分享空间,让你可以随时记录生活中的点滴、灵感与感悟。
|
||||
支持文字、标签,所有注册用户都可以发布。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ pubDate: 2024-01-20
|
||||
author: NovaBlog
|
||||
tags: [React, 动效, 组件, 教程]
|
||||
category: 教程
|
||||
heroImage: /images/react-components.jpg
|
||||
heroImage: '/images/hello-world.jpg'
|
||||
---
|
||||
|
||||
import AnimatedCard from '../../components/react/AnimatedCard';
|
||||
|
||||
@@ -105,6 +105,7 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<a href="/" class="text-foreground/70 hover:text-foreground transition-colors">首页</a>
|
||||
<a href="/blog" class="text-foreground/70 hover:text-foreground transition-colors">博客</a>
|
||||
<a href="/micro" class="text-foreground/70 hover:text-foreground transition-colors">微语</a>
|
||||
<a href="/categories" class="text-foreground/70 hover:text-foreground transition-colors">分类</a>
|
||||
<a href="/tags" class="text-foreground/70 hover:text-foreground transition-colors">标签</a>
|
||||
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors">关于</a>
|
||||
@@ -151,6 +152,7 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
|
||||
<div class="content-width py-4 flex flex-col gap-3">
|
||||
<a href="/" class="text-foreground/70 hover:text-foreground transition-colors py-2">首页</a>
|
||||
<a href="/blog" class="text-foreground/70 hover:text-foreground transition-colors py-2">博客</a>
|
||||
<a href="/micro" class="text-foreground/70 hover:text-foreground transition-colors py-2">微语</a>
|
||||
<a href="/categories" class="text-foreground/70 hover:text-foreground transition-colors py-2">分类</a>
|
||||
<a href="/tags" class="text-foreground/70 hover:text-foreground transition-colors py-2">标签</a>
|
||||
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors py-2">关于</a>
|
||||
@@ -180,6 +182,7 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
|
||||
<h3 class="font-semibold text-lg mb-4">快速链接</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><a href="/blog" class="text-foreground/60 hover:text-primary-500 transition-colors">全部文章</a></li>
|
||||
<li><a href="/micro" class="text-foreground/60 hover:text-primary-500 transition-colors">微语</a></li>
|
||||
<li><a href="/tags" class="text-foreground/60 hover:text-primary-500 transition-colors">标签分类</a></li>
|
||||
<li><a href="/about" class="text-foreground/60 hover:text-primary-500 transition-colors">关于我</a></li>
|
||||
<li><a href="/rss.xml" class="text-foreground/60 hover:text-primary-500 transition-colors">RSS 订阅</a></li>
|
||||
|
||||
12
src/pages/micro.astro
Normal file
12
src/pages/micro.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import MicroPage from '../components/react/MicroPage';
|
||||
---
|
||||
|
||||
<BaseLayout title="微语 - NovaBlog" description="分享生活点滴,记录每一个精彩瞬间">
|
||||
<div class="py-12">
|
||||
<div class="content-width">
|
||||
<MicroPage client:load />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
Reference in New Issue
Block a user