Merge pull request 'feat: 添加微语功能' (#10) from taishi-update into main
Reviewed-on: #10
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,15 @@ func main() {
|
||||
api.GET("/likes", likeHandler.GetLikeStatus)
|
||||
api.POST("/likes", likeHandler.ToggleLike) // 允许访客点赞(基于 IP Hash)
|
||||
|
||||
// 微语公开接口
|
||||
api.GET("/micro", microHandler.GetMicros)
|
||||
api.GET("/micro/stats", microHandler.GetMicroStats)
|
||||
api.GET("/micro/heatmap", microHandler.GetMicroHeatmap)
|
||||
api.GET("/micro/tags", microHandler.GetMicroTags)
|
||||
api.GET("/micro/:id", microHandler.GetMicro)
|
||||
api.POST("/micro/:id/like", microHandler.ToggleMicroLike) // 允许访客点赞
|
||||
api.GET("/micro-comments", microHandler.GetMicroComments) // 获取微语评论
|
||||
|
||||
// 需要认证的接口
|
||||
authGroup := api.Group("")
|
||||
authGroup.Use(middleware.AuthMiddleware(jwtManager))
|
||||
@@ -81,6 +91,14 @@ func main() {
|
||||
// 评论相关(需要登录才能评论)
|
||||
authGroup.POST("/comments", commentHandler.CreateComment)
|
||||
authGroup.DELETE("/comments/:id", commentHandler.DeleteComment)
|
||||
|
||||
// 微语相关(需要登录才能发布和删除)
|
||||
authGroup.POST("/micro", microHandler.CreateMicro)
|
||||
authGroup.DELETE("/micro/:id", microHandler.DeleteMicro)
|
||||
|
||||
// 微语评论相关
|
||||
authGroup.POST("/micro-comments", microHandler.CreateMicroComment)
|
||||
authGroup.DELETE("/micro-comments/:id", microHandler.DeleteMicroComment)
|
||||
}
|
||||
|
||||
// 管理员接口
|
||||
|
||||
@@ -54,6 +54,9 @@ func autoMigrate() error {
|
||||
&models.Like{},
|
||||
&models.LikeCount{},
|
||||
&models.PostMeta{},
|
||||
&models.Micro{},
|
||||
&models.MicroLike{},
|
||||
&models.MicroComment{},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
595
server/internal/handlers/micro.go
Normal file
595
server/internal/handlers/micro.go
Normal file
@@ -0,0 +1,595 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"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"
|
||||
)
|
||||
|
||||
// MicroHandler 微语处理器
|
||||
type MicroHandler struct{}
|
||||
|
||||
// NewMicroHandler 创建微语处理器
|
||||
func NewMicroHandler() *MicroHandler {
|
||||
return &MicroHandler{}
|
||||
}
|
||||
|
||||
// CreateMicroRequest 创建微语请求
|
||||
type CreateMicroRequest struct {
|
||||
Content string `json:"content" binding:"required,min=1,max=2000"`
|
||||
Images []string `json:"images"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// CreateMicro 创建微语
|
||||
func (h *MicroHandler) CreateMicro(c *gin.Context) {
|
||||
userID, isLoggedIn := middleware.GetUserID(c)
|
||||
if !isLoggedIn || 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
|
||||
}
|
||||
|
||||
// 将图片数组转为 JSON 字符串
|
||||
var imagesJSON string
|
||||
if len(req.Images) > 0 {
|
||||
imagesBytes, _ := json.Marshal(req.Images)
|
||||
imagesJSON = string(imagesBytes)
|
||||
}
|
||||
|
||||
// 将标签数组转为 JSON 字符串
|
||||
var tagsJSON string
|
||||
if len(req.Tags) > 0 {
|
||||
tagsBytes, _ := json.Marshal(req.Tags)
|
||||
tagsJSON = string(tagsBytes)
|
||||
}
|
||||
|
||||
micro := models.Micro{
|
||||
UserID: userID,
|
||||
Content: req.Content,
|
||||
Images: imagesJSON,
|
||||
Tags: tagsJSON,
|
||||
}
|
||||
|
||||
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, micro)
|
||||
}
|
||||
|
||||
// GetMicros 获取微语列表
|
||||
func (h *MicroHandler) GetMicros(c *gin.Context) {
|
||||
// 分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 50 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 标签过滤
|
||||
tag := c.Query("tag")
|
||||
|
||||
var micros []models.Micro
|
||||
var total int64
|
||||
|
||||
query := database.DB.Model(&models.Micro{}).Where("deleted_at IS NULL")
|
||||
|
||||
// 如果有标签过滤
|
||||
if tag != "" {
|
||||
query = query.Where("tags LIKE ?", "%\""+tag+"\"%")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 获取当前用户的点赞状态
|
||||
userID, isLoggedIn := middleware.GetUserID(c)
|
||||
userLikes := make(map[uint]bool)
|
||||
if isLoggedIn && userID > 0 {
|
||||
var likes []models.MicroLike
|
||||
microIDs := make([]uint, len(micros))
|
||||
for i, m := range micros {
|
||||
microIDs[i] = m.ID
|
||||
}
|
||||
database.DB.Where("micro_id IN ? AND user_id = ?", microIDs, userID).Find(&likes)
|
||||
for _, like := range likes {
|
||||
userLikes[like.MicroID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
type MicroResponse struct {
|
||||
models.Micro
|
||||
IsLiked bool `json:"is_liked"`
|
||||
}
|
||||
|
||||
responses := make([]MicroResponse, len(micros))
|
||||
for i, m := range micros {
|
||||
responses[i] = MicroResponse{
|
||||
Micro: m,
|
||||
IsLiked: userLikes[m.ID],
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetMicro 获取单条微语
|
||||
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": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var micro models.Micro
|
||||
if err := database.DB.Preload("User").First(µ, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "微语不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取点赞状态
|
||||
userID, isLoggedIn := middleware.GetUserID(c)
|
||||
isLiked := false
|
||||
if isLoggedIn && userID > 0 {
|
||||
var like models.MicroLike
|
||||
if err := database.DB.Where("micro_id = ? AND user_id = ?", micro.ID, userID).First(&like).Error; err == nil {
|
||||
isLiked = true
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": micro,
|
||||
"is_liked": isLiked,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteMicro 删除微语
|
||||
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": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var micro models.Micro
|
||||
if err := database.DB.First(µ, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "微语不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"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": "删除成功"})
|
||||
}
|
||||
|
||||
// ToggleMicroLike 切换点赞状态
|
||||
func (h *MicroHandler) ToggleMicroLike(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查微语是否存在
|
||||
var micro models.Micro
|
||||
if err := database.DB.First(µ, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "微语不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取失败"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, isLoggedIn := middleware.GetUserID(c)
|
||||
|
||||
var existingLike models.MicroLike
|
||||
var likeErr error
|
||||
|
||||
if isLoggedIn && userID > 0 {
|
||||
// 登录用户:按 user_id 查找
|
||||
likeErr = database.DB.Where("micro_id = ? AND user_id = ?", id, userID).First(&existingLike).Error
|
||||
} else {
|
||||
// 访客:按 IP Hash 查找
|
||||
ipHash := getIPHash(c)
|
||||
likeErr = database.DB.Where("micro_id = ? AND ip_hash = ?", id, ipHash).First(&existingLike).Error
|
||||
}
|
||||
|
||||
if likeErr == gorm.ErrRecordNotFound {
|
||||
// 创建点赞
|
||||
newLike := models.MicroLike{
|
||||
MicroID: uint(id),
|
||||
}
|
||||
if isLoggedIn && userID > 0 {
|
||||
newLike.UserID = &userID
|
||||
} else {
|
||||
newLike.IPHash = getIPHash(c)
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&newLike).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "点赞失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新点赞计数
|
||||
database.DB.Model(µ).Update("like_count", gorm.Expr("like_count + 1"))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"liked": true,
|
||||
"like_count": micro.LikeCount + 1,
|
||||
})
|
||||
} else if likeErr == nil {
|
||||
// 取消点赞
|
||||
if err := database.DB.Delete(&existingLike).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "取消点赞失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新点赞计数
|
||||
newCount := micro.LikeCount - 1
|
||||
if newCount < 0 {
|
||||
newCount = 0
|
||||
}
|
||||
database.DB.Model(µ).Update("like_count", newCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"liked": false,
|
||||
"like_count": newCount,
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// GetMicroHeatmap 获取热力图数据
|
||||
func (h *MicroHandler) GetMicroHeatmap(c *gin.Context) {
|
||||
yearStr := c.DefaultQuery("year", strconv.Itoa(time.Now().Year()))
|
||||
year, err := strconv.Atoi(yearStr)
|
||||
if err != nil {
|
||||
year = time.Now().Year()
|
||||
}
|
||||
|
||||
// 计算年份的起止日期
|
||||
startDate := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
endDate := time.Date(year, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
|
||||
// 按日期分组统计
|
||||
type DayCount struct {
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
var results []DayCount
|
||||
|
||||
database.DB.Model(&models.Micro{}).
|
||||
Select("DATE(created_at) as date, COUNT(*) as count").
|
||||
Where("created_at >= ? AND created_at <= ? AND deleted_at IS NULL", startDate, endDate).
|
||||
Group("DATE(created_at)").
|
||||
Find(&results)
|
||||
|
||||
// 转换为 map
|
||||
data := make(map[string]int)
|
||||
for _, r := range results {
|
||||
data[r.Date] = r.Count
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"year": year,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMicroStats 获取统计数据
|
||||
func (h *MicroHandler) GetMicroStats(c *gin.Context) {
|
||||
var totalMicros int64
|
||||
var totalLikes int64
|
||||
var totalComments int64
|
||||
var monthMicros int64
|
||||
|
||||
// 总微语数
|
||||
database.DB.Model(&models.Micro{}).Count(&totalMicros)
|
||||
|
||||
// 总点赞数
|
||||
database.DB.Model(&models.MicroLike{}).Count(&totalLikes)
|
||||
|
||||
// 总评论数
|
||||
database.DB.Model(&models.MicroComment{}).Count(&totalComments)
|
||||
|
||||
// 本月发布数
|
||||
now := time.Now()
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
database.DB.Model(&models.Micro{}).Where("created_at >= ? AND deleted_at IS NULL", monthStart).Count(&monthMicros)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_micros": totalMicros,
|
||||
"total_likes": totalLikes,
|
||||
"total_comments": totalComments,
|
||||
"month_micros": monthMicros,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMicroTags 获取热门标签
|
||||
func (h *MicroHandler) GetMicroTags(c *gin.Context) {
|
||||
// 获取所有微语的标签
|
||||
var micros []models.Micro
|
||||
database.DB.Where("tags IS NOT NULL AND tags != '' AND deleted_at IS NULL").Select("tags").Find(µs)
|
||||
|
||||
// 统计标签使用次数
|
||||
tagCount := make(map[string]int)
|
||||
for _, micro := range micros {
|
||||
if micro.Tags == "" {
|
||||
continue
|
||||
}
|
||||
var tags []string
|
||||
if err := json.Unmarshal([]byte(micro.Tags), &tags); err == nil {
|
||||
for _, tag := range tags {
|
||||
tagCount[tag]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为排序后的列表
|
||||
type TagItem struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
var tags []TagItem
|
||||
for name, count := range tagCount {
|
||||
tags = append(tags, TagItem{Name: name, Count: count})
|
||||
}
|
||||
|
||||
// 按使用次数排序
|
||||
for i := 0; i < len(tags); i++ {
|
||||
for j := i + 1; j < len(tags); j++ {
|
||||
if tags[j].Count > tags[i].Count {
|
||||
tags[i], tags[j] = tags[j], tags[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只返回前20个
|
||||
if len(tags) > 20 {
|
||||
tags = tags[:20]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tags": tags,
|
||||
})
|
||||
}
|
||||
|
||||
// getIPHash 获取 IP 的哈希值
|
||||
func getIPHash(c *gin.Context) string {
|
||||
ip := c.ClientIP()
|
||||
hash := sha256.Sum256([]byte(ip + "micro-salt"))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// CreateMicroCommentRequest 创建微语评论请求
|
||||
type CreateMicroCommentRequest struct {
|
||||
MicroID uint `json:"micro_id" binding:"required"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
Content string `json:"content" binding:"required,min=1,max=2000"`
|
||||
}
|
||||
|
||||
// CreateMicroComment 创建微语评论
|
||||
func (h *MicroHandler) CreateMicroComment(c *gin.Context) {
|
||||
userID, isLoggedIn := middleware.GetUserID(c)
|
||||
if !isLoggedIn || userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后再评论"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateMicroCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查微语是否存在
|
||||
var micro models.Micro
|
||||
if err := database.DB.First(µ, req.MicroID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "微语不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
comment := models.MicroComment{
|
||||
MicroID: req.MicroID,
|
||||
UserID: userID,
|
||||
ParentID: req.ParentID,
|
||||
Content: req.Content,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&comment).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新评论计数
|
||||
database.DB.Model(µ).Update("comment_count", gorm.Expr("comment_count + 1"))
|
||||
|
||||
// 加载用户信息
|
||||
database.DB.Preload("User").First(&comment, comment.ID)
|
||||
|
||||
c.JSON(http.StatusCreated, comment)
|
||||
}
|
||||
|
||||
// GetMicroComments 获取微语评论列表
|
||||
func (h *MicroHandler) GetMicroComments(c *gin.Context) {
|
||||
microIDStr := c.Query("micro_id")
|
||||
if microIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "micro_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
microID, err := strconv.ParseUint(microIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid micro_id"})
|
||||
return
|
||||
}
|
||||
|
||||
// 分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 获取顶级评论(非回复)
|
||||
var comments []models.MicroComment
|
||||
var total int64
|
||||
|
||||
query := database.DB.Model(&models.MicroComment{}).
|
||||
Where("micro_id = ? AND parent_id IS NULL", microID)
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
if err := query.
|
||||
Preload("User").
|
||||
Preload("Replies.User").
|
||||
Order("created_at DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&comments).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取评论失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 手动加载每个评论的用户信息
|
||||
for i := range comments {
|
||||
if comments[i].UserID > 0 {
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, comments[i].UserID).Error; err == nil {
|
||||
comments[i].User = user
|
||||
}
|
||||
}
|
||||
// 加载回复的用户信息
|
||||
for j := range comments[i].Replies {
|
||||
if comments[i].Replies[j].UserID > 0 {
|
||||
var replyUser models.User
|
||||
if err := database.DB.First(&replyUser, comments[i].Replies[j].UserID).Error; err == nil {
|
||||
comments[i].Replies[j].User = replyUser
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": comments,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
"total_page": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteMicroComment 删除微语评论
|
||||
func (h *MicroHandler) DeleteMicroComment(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 comment id"})
|
||||
return
|
||||
}
|
||||
|
||||
var comment models.MicroComment
|
||||
if err := database.DB.First(&comment, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限:本人或管理员可删除
|
||||
if comment.UserID != userID && role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 软删除
|
||||
if err := database.DB.Delete(&comment).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新评论计数
|
||||
database.DB.Model(&models.Micro{}).Where("id = ?", comment.MicroID).
|
||||
Update("comment_count", gorm.Expr("comment_count - 1"))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
|
||||
}
|
||||
@@ -62,3 +62,41 @@ type PostMeta struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Micro 微语模型
|
||||
type Micro 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 array of image URLs
|
||||
Tags string `json:"tags" gorm:"type:text"` // JSON array of tags
|
||||
LikeCount int `json:"like_count" gorm:"default:0"`
|
||||
CommentCount int `json:"comment_count" gorm:"default:0"`
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// MicroLike 微语点赞模型
|
||||
type MicroLike struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
MicroID uint `json:"micro_id" gorm:"uniqueIndex:idx_micro_user;not null"`
|
||||
UserID *uint `json:"user_id" gorm:"uniqueIndex:idx_micro_user;index"`
|
||||
IPHash string `json:"-" gorm:"uniqueIndex:idx_micro_ip;size:64"`
|
||||
}
|
||||
|
||||
// MicroComment 微语评论模型
|
||||
type MicroComment 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"`
|
||||
MicroID uint `json:"micro_id" gorm:"index;not null"`
|
||||
UserID uint `json:"user_id" gorm:"index;not null"`
|
||||
ParentID *uint `json:"parent_id" gorm:"index"`
|
||||
Content string `json:"content" gorm:"type:text;not null"`
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
Replies []MicroComment `json:"replies,omitempty" gorm:"foreignKey:ParentID"`
|
||||
}
|
||||
|
||||
230
src/components/HeatmapCalendar.vue
Normal file
230
src/components/HeatmapCalendar.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="heatmap-calendar">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">{{ title }}</h3>
|
||||
<div class="flex items-center gap-2 text-sm text-foreground/60">
|
||||
<span>{{ totalContributions }} 条微语</span>
|
||||
<span class="text-foreground/30">|</span>
|
||||
<span>{{ activeDays }} 天活跃</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热力图网格 -->
|
||||
<div class="overflow-x-auto hide-scrollbar">
|
||||
<div class="flex gap-1" style="min-width: fit-content;">
|
||||
<div v-for="(week, weekIndex) in weeks" :key="weekIndex" class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="(day, dayIndex) in week"
|
||||
:key="dayIndex"
|
||||
class="w-3 h-3 rounded-sm cursor-pointer transition-all duration-200 hover:ring-2 hover:ring-primary-400"
|
||||
:class="getLevelClass(day.level)"
|
||||
:style="{ backgroundColor: getLevelColor(day.level) }"
|
||||
@mouseenter="showTooltip($event, day)"
|
||||
@mouseleave="hideTooltip"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图例 -->
|
||||
<div class="flex items-center justify-end gap-2 mt-3 text-xs text-foreground/40">
|
||||
<span>少</span>
|
||||
<div class="flex gap-1">
|
||||
<div class="w-3 h-3 rounded-sm" :style="{ backgroundColor: getLevelColor(0) }"></div>
|
||||
<div class="w-3 h-3 rounded-sm" :style="{ backgroundColor: getLevelColor(1) }"></div>
|
||||
<div class="w-3 h-3 rounded-sm" :style="{ backgroundColor: getLevelColor(2) }"></div>
|
||||
<div class="w-3 h-3 rounded-sm" :style="{ backgroundColor: getLevelColor(3) }"></div>
|
||||
<div class="w-3 h-3 rounded-sm" :style="{ backgroundColor: getLevelColor(4) }"></div>
|
||||
</div>
|
||||
<span>多</span>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<Teleport to="body">
|
||||
<Transition name="tooltip">
|
||||
<div
|
||||
v-if="tooltipVisible"
|
||||
class="fixed z-[9999] px-3 py-2 text-sm bg-gray-900 dark:bg-gray-700 text-white rounded-lg shadow-lg pointer-events-none"
|
||||
:style="{ left: tooltipX + 'px', top: tooltipY + 'px' }"
|
||||
>
|
||||
<div class="font-medium">{{ tooltipDate }}</div>
|
||||
<div class="text-gray-300">{{ tooltipCount }} 条微语</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
|
||||
interface DayData {
|
||||
date: Date;
|
||||
count: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
data?: Record<string, number>;
|
||||
year?: number;
|
||||
colorScheme?: 'green' | 'blue' | 'purple' | 'orange';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '活动热力图',
|
||||
year: () => new Date().getFullYear(),
|
||||
colorScheme: 'green',
|
||||
});
|
||||
|
||||
// 颜色方案
|
||||
const colorSchemes = {
|
||||
green: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'],
|
||||
blue: ['#ebedf0', '#a5d6ff', '#79c0ff', '#58a6ff', '#1f6feb'],
|
||||
purple: ['#ebedf0', '#d2b4fe', '#c084fc', '#a855f7', '#7c3aed'],
|
||||
orange: ['#ebedf0', '#fed7aa', '#fdba74', '#fb923c', '#ea580c'],
|
||||
};
|
||||
|
||||
// 暗黑模式颜色方案
|
||||
const darkColorSchemes = {
|
||||
green: ['#161b22', '#0e4429', '#006d32', '#26a641', '#39d353'],
|
||||
blue: ['#161b22', '#0a3069', '#0550ae', '#0969da', '#1f6feb'],
|
||||
purple: ['#161b22', '#3b0764', '#6b21a8', '#9333ea', '#a855f7'],
|
||||
orange: ['#161b22', '#431407', '#7c2d12', '#c2410c', '#ea580c'],
|
||||
};
|
||||
|
||||
const isDark = ref(false);
|
||||
|
||||
// 检查暗黑模式
|
||||
onMounted(() => {
|
||||
isDark.value = document.documentElement.classList.contains('dark');
|
||||
|
||||
// 监听主题变化
|
||||
const observer = new MutationObserver(() => {
|
||||
isDark.value = document.documentElement.classList.contains('dark');
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
});
|
||||
|
||||
// 获取颜色
|
||||
function getLevelColor(level: number): string {
|
||||
const schemes = isDark.value ? darkColorSchemes : colorSchemes;
|
||||
return schemes[props.colorScheme][level];
|
||||
}
|
||||
|
||||
// 获取级别样式类
|
||||
function getLevelClass(level: number): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 生成一年的日期数据
|
||||
const weeks = computed<DayData[][]>(() => {
|
||||
const year = props.year;
|
||||
const startDate = new Date(year, 0, 1);
|
||||
const endDate = new Date(year, 11, 31);
|
||||
|
||||
// 调整到周日开始
|
||||
const firstDay = startDate.getDay();
|
||||
const firstSunday = new Date(startDate);
|
||||
firstSunday.setDate(startDate.getDate() - firstDay);
|
||||
|
||||
const weeks: DayData[][] = [];
|
||||
let currentWeek: DayData[] = [];
|
||||
let currentDate = new Date(firstSunday);
|
||||
|
||||
while (currentDate <= endDate || currentWeek.length > 0) {
|
||||
const dateStr = formatDateKey(currentDate);
|
||||
const count = props.data?.[dateStr] || 0;
|
||||
|
||||
currentWeek.push({
|
||||
date: new Date(currentDate),
|
||||
count,
|
||||
level: getLevel(count),
|
||||
});
|
||||
|
||||
if (currentWeek.length === 7) {
|
||||
weeks.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
|
||||
// 防止无限循环
|
||||
if (weeks.length > 54) break;
|
||||
}
|
||||
|
||||
return weeks;
|
||||
});
|
||||
|
||||
// 总贡献数
|
||||
const totalContributions = computed(() => {
|
||||
return Object.values(props.data || {}).reduce((sum, count) => sum + count, 0);
|
||||
});
|
||||
|
||||
// 活跃天数
|
||||
const activeDays = computed(() => {
|
||||
return Object.values(props.data || {}).filter(count => count > 0).length;
|
||||
});
|
||||
|
||||
// 根据数量获取级别
|
||||
function getLevel(count: number): number {
|
||||
if (count === 0) return 0;
|
||||
if (count <= 2) return 1;
|
||||
if (count <= 4) return 2;
|
||||
if (count <= 6) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
// 格式化日期为 key
|
||||
function formatDateKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Tooltip 相关
|
||||
const tooltipVisible = ref(false);
|
||||
const tooltipX = ref(0);
|
||||
const tooltipY = ref(0);
|
||||
const tooltipDate = ref('');
|
||||
const tooltipCount = ref(0);
|
||||
|
||||
function showTooltip(event: MouseEvent, day: DayData) {
|
||||
const rect = (event.target as HTMLElement).getBoundingClientRect();
|
||||
tooltipX.value = rect.left + rect.width / 2;
|
||||
tooltipY.value = rect.top - 50;
|
||||
|
||||
tooltipDate.value = day.date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
tooltipCount.value = day.count;
|
||||
tooltipVisible.value = true;
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
tooltipVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heatmap-calendar {
|
||||
@apply p-4 bg-background border border-border rounded-xl;
|
||||
}
|
||||
|
||||
.tooltip-enter-active,
|
||||
.tooltip-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.tooltip-enter-from,
|
||||
.tooltip-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
285
src/components/MicroCommentSection.vue
Normal file
285
src/components/MicroCommentSection.vue
Normal file
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div class="micro-comment-section">
|
||||
<h4 class="text-base font-semibold mb-4">评论</h4>
|
||||
|
||||
<!-- 评论输入框 -->
|
||||
<div v-if="isLoggedIn" class="mb-6">
|
||||
<textarea
|
||||
v-model="newComment"
|
||||
placeholder="写下你的评论..."
|
||||
class="w-full p-3 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none text-sm"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="flex justify-end mt-2">
|
||||
<button
|
||||
@click="submitComment"
|
||||
:disabled="!newComment.trim() || submitting"
|
||||
class="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{{ submitting ? '发布中...' : '发布评论' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未登录提示 -->
|
||||
<div v-else class="mb-6 p-3 bg-muted rounded-lg text-center text-sm">
|
||||
<p class="text-foreground/60">
|
||||
<a href="/login" class="text-primary-500 hover:underline">登录</a> 后参与评论
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 评论列表 -->
|
||||
<div v-if="loading" class="text-center py-4">
|
||||
<div class="animate-spin w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full mx-auto"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="comments.length === 0" class="text-center py-4 text-foreground/40 text-sm">
|
||||
<p>暂无评论</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="comment in comments" :key="comment.id" class="comment-item">
|
||||
<div class="flex gap-3">
|
||||
<!-- 头像 -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||
<span class="text-primary-600 dark:text-primary-400 text-sm font-medium">
|
||||
{{ getInitial(comment.user) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-sm">{{ getDisplayName(comment.user) }}</span>
|
||||
<span class="text-xs text-foreground/40">{{ formatDate(comment.created_at) }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-foreground/80">{{ comment.content }}</div>
|
||||
|
||||
<!-- 回复按钮 -->
|
||||
<button
|
||||
@click="replyTo = comment.id"
|
||||
class="text-xs text-primary-500 hover:underline mt-1"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回复输入框 -->
|
||||
<div v-if="replyTo === comment.id" class="mt-3 ml-11">
|
||||
<textarea
|
||||
v-model="replyContent"
|
||||
placeholder="写下你的回复..."
|
||||
class="w-full p-2 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none text-sm"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<button @click="replyTo = null" class="btn-secondary text-xs">取消</button>
|
||||
<button
|
||||
@click="submitReply(comment.id)"
|
||||
:disabled="!replyContent.trim() || submitting"
|
||||
class="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子评论 -->
|
||||
<div v-if="comment.replies && comment.replies.length > 0" class="mt-3 ml-11 space-y-3">
|
||||
<div v-for="reply in comment.replies" :key="reply.id" class="flex gap-2">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||
<span class="text-primary-600 dark:text-primary-400 text-xs font-medium">
|
||||
{{ getInitial(reply.user) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="font-medium text-xs">{{ getDisplayName(reply.user) }}</span>
|
||||
<span class="text-xs text-foreground/40">{{ formatDate(reply.created_at) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-foreground/80">{{ reply.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
microId: number;
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
apiBaseUrl: 'http://localhost:8080/api',
|
||||
});
|
||||
|
||||
// 状态
|
||||
const comments = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const newComment = ref('');
|
||||
const replyTo = ref<number | null>(null);
|
||||
const replyContent = ref('');
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!localStorage.getItem('token');
|
||||
});
|
||||
|
||||
// 获取认证头
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const token = localStorage.getItem('token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// 加载评论
|
||||
async function loadComments() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${props.apiBaseUrl}/micro-comments?micro_id=${props.microId}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
comments.value = data.data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load comments:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 提交评论
|
||||
async function submitComment() {
|
||||
if (!newComment.value.trim() || submitting.value) return;
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const response = await fetch(`${props.apiBaseUrl}/micro-comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
micro_id: props.microId,
|
||||
content: newComment.value,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
comments.value.unshift(data);
|
||||
newComment.value = '';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || '发布失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit comment:', error);
|
||||
alert('发布失败,请稍后重试');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 提交回复
|
||||
async function submitReply(parentId: number) {
|
||||
if (!replyContent.value.trim() || submitting.value) return;
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const response = await fetch(`${props.apiBaseUrl}/micro-comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
micro_id: props.microId,
|
||||
parent_id: parentId,
|
||||
content: replyContent.value,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// 找到父评论并添加回复
|
||||
const parentComment = comments.value.find(c => c.id === parentId);
|
||||
if (parentComment) {
|
||||
if (!parentComment.replies) {
|
||||
parentComment.replies = [];
|
||||
}
|
||||
parentComment.replies.push(data);
|
||||
}
|
||||
replyContent.value = '';
|
||||
replyTo.value = null;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || '回复失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit reply:', error);
|
||||
alert('回复失败,请稍后重试');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户首字母
|
||||
function getInitial(user: any): string {
|
||||
if (!user) return '?';
|
||||
const name = user.nickname || user.username || '匿';
|
||||
return name[0].toUpperCase();
|
||||
}
|
||||
|
||||
// 获取用户显示名称
|
||||
function getDisplayName(user: any): string {
|
||||
if (!user) return '匿名用户';
|
||||
return user.nickname || user.username || '匿名用户';
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
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 < 30) return `${days} 天前`;
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadComments();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.micro-comment-section {
|
||||
@apply text-sm;
|
||||
}
|
||||
</style>
|
||||
518
src/components/MicroFeed.vue
Normal file
518
src/components/MicroFeed.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="micro-page">
|
||||
<!-- 发布框 -->
|
||||
<div class="card mb-6">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="newContent"
|
||||
placeholder="分享你的想法..."
|
||||
class="w-full p-4 bg-muted/50 border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
rows="4"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Emoji 按钮 -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="showEmojiPicker = !showEmojiPicker"
|
||||
class="btn-ghost p-2 rounded-lg"
|
||||
title="添加表情"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Emoji 选择面板 -->
|
||||
<div
|
||||
v-if="showEmojiPicker"
|
||||
class="absolute left-0 top-full mt-1 bg-white dark:bg-gray-800 border border-border rounded-lg shadow-lg p-3 z-20 w-72"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="emoji in commonEmojis"
|
||||
:key="emoji"
|
||||
@click="insertEmoji(emoji)"
|
||||
class="w-8 h-8 flex items-center justify-center text-xl hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 图片上传按钮 -->
|
||||
<button
|
||||
@click="triggerImageUpload"
|
||||
class="btn-ghost p-2 rounded-lg"
|
||||
title="上传图片"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref="imageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleImageSelect"
|
||||
/>
|
||||
<!-- 标签按钮 -->
|
||||
<div class="relative" ref="tagInputRef">
|
||||
<button
|
||||
@click="showTagInput = !showTagInput"
|
||||
class="btn-ghost p-2 rounded-lg"
|
||||
title="添加标签"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 标签输入面板 -->
|
||||
<div
|
||||
v-if="showTagInput"
|
||||
class="absolute left-0 top-full mt-1 bg-white dark:bg-gray-800 border border-border rounded-lg shadow-lg p-3 z-20 w-64"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input
|
||||
v-model="tagInput"
|
||||
type="text"
|
||||
placeholder="输入标签(回车添加)"
|
||||
class="flex-1 px-2 py-1 text-sm border border-border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
@keydown.enter="addTag"
|
||||
/>
|
||||
<button
|
||||
@click="addTag"
|
||||
class="px-2 py-1 text-sm bg-primary-500 text-white rounded hover:bg-primary-600"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="newTags.length > 0" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="(tag, index) in newTags"
|
||||
:key="index"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full"
|
||||
>
|
||||
#{{ tag }}
|
||||
<button @click="removeTag(index)" class="hover:text-red-500">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 pt-2 border-t border-border text-xs text-foreground/40">
|
||||
最多添加 5 个标签
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="publishMicro"
|
||||
:disabled="!newContent.trim() || publishing"
|
||||
class="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{{ publishing ? '发布中...' : '发布' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 已选标签显示 -->
|
||||
<div v-if="newTags.length > 0 && !showTagInput" class="mt-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="(tag, index) in newTags"
|
||||
:key="index"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full"
|
||||
>
|
||||
#{{ tag }}
|
||||
<button @click="removeTag(index)" class="hover:text-red-500">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 图片预览 -->
|
||||
<div v-if="newImages.length > 0 || uploadingImages" class="mt-3 grid grid-cols-3 gap-2">
|
||||
<!-- 上传进度 -->
|
||||
<div v-if="uploadingImages" class="relative aspect-square rounded-lg overflow-hidden bg-muted flex flex-col items-center justify-center p-2">
|
||||
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-primary-500 transition-all duration-100 rounded-full"
|
||||
:style="{ width: uploadProgress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-foreground/60">{{ uploadProgress }}%</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(img, index) in newImages"
|
||||
:key="index"
|
||||
class="relative aspect-square rounded-lg overflow-hidden bg-muted"
|
||||
>
|
||||
<img :src="img" class="w-full h-full object-cover" />
|
||||
<button
|
||||
@click="removeImage(index)"
|
||||
class="absolute top-1 right-1 p-1 bg-black/50 rounded-full text-white hover:bg-black/70"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 微语列表 -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto"></div>
|
||||
<p class="mt-2 text-foreground/40">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="micros.length === 0" class="text-center py-16 text-foreground/40">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<p>暂无微语,来发布第一条吧!</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<MicroPost
|
||||
v-for="micro in micros"
|
||||
:key="micro.id"
|
||||
:id="micro.id"
|
||||
:author="micro.user?.nickname || micro.user?.username || '博主'"
|
||||
:content="micro.content"
|
||||
:images="parseImages(micro.images)"
|
||||
:tags="parseTags(micro.tags)"
|
||||
:created-at="micro.created_at"
|
||||
:like-count="micro.like_count"
|
||||
:comment-count="micro.comment_count"
|
||||
:is-liked="micro.is_liked"
|
||||
:api-base-url="apiBaseUrl"
|
||||
@like="handleLikeUpdate"
|
||||
@tag-click="handleTagClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="pagination.totalPage > 1" class="text-center py-4">
|
||||
<button
|
||||
v-if="pagination.page < pagination.totalPage"
|
||||
@click="loadMore"
|
||||
:disabled="loadingMore"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ loadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import MicroPost from './MicroPost.vue';
|
||||
|
||||
interface Props {
|
||||
apiBaseUrl?: string;
|
||||
imageUploadUrl?: string;
|
||||
imageUploadToken?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
apiBaseUrl: 'http://localhost:8080/api',
|
||||
imageUploadUrl: 'https://picturebed.jiao77.cn/api/index.php',
|
||||
imageUploadToken: 'blog',
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'published'): void;
|
||||
}>();
|
||||
|
||||
// 状态
|
||||
const micros = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const publishing = ref(false);
|
||||
const newContent = ref('');
|
||||
const newImages = ref<string[]>([]);
|
||||
const newTags = ref<string[]>([]);
|
||||
const tagInput = ref('');
|
||||
const imageInput = ref<HTMLInputElement | null>(null);
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const tagInputRef = ref<HTMLElement | null>(null);
|
||||
const uploadingImages = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const showEmojiPicker = ref(false);
|
||||
const showTagInput = ref(false);
|
||||
const currentTag = ref('');
|
||||
|
||||
// 常用 emoji 列表
|
||||
const commonEmojis = [
|
||||
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂',
|
||||
'😉', '😌', '😍', '🥰', '😘', '😋', '😛', '😜', '🤪', '😝',
|
||||
'🤗', '🤔', '🤭', '🤫', '🤥', '😶', '😐', '😑', '😏', '😒',
|
||||
'🙄', '😬', '😮', '🥱', '😴', '🤤', '😷', '🤒', '🤕', '🤢',
|
||||
'👍', '👎', '👏', '🙌', '🤝', '🙏', '💪', '🎉', '🎊', '💯',
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '❣️', '💕',
|
||||
'🔥', '⭐', '🌟', '✨', '💫', '🎯', '🏆', '🚀', '💡', '📌',
|
||||
];
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
totalPage: 0,
|
||||
});
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!localStorage.getItem('token');
|
||||
});
|
||||
|
||||
// 获取认证头
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const token = localStorage.getItem('token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// 加载微语列表
|
||||
async function loadMicros(page = 1) {
|
||||
try {
|
||||
let url = `${props.apiBaseUrl}/micro?page=${page}&page_size=${pagination.value.pageSize}`;
|
||||
if (currentTag.value) {
|
||||
url += `&tag=${encodeURIComponent(currentTag.value)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers: getAuthHeaders() });
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (page === 1) {
|
||||
micros.value = data.data || [];
|
||||
} else {
|
||||
micros.value.push(...(data.data || []));
|
||||
}
|
||||
pagination.value = data.pagination || pagination.value;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load micros:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
async function loadMore() {
|
||||
loadingMore.value = true;
|
||||
await loadMicros(pagination.value.page + 1);
|
||||
}
|
||||
|
||||
// 发布微语
|
||||
async function publishMicro() {
|
||||
if (!newContent.value.trim() || publishing.value) return;
|
||||
|
||||
// 检查登录状态
|
||||
if (!isLoggedIn.value) {
|
||||
alert('请先登录');
|
||||
return;
|
||||
}
|
||||
|
||||
publishing.value = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${props.apiBaseUrl}/micro`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: newContent.value,
|
||||
images: newImages.value,
|
||||
tags: newTags.value,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// 添加到列表开头
|
||||
micros.value.unshift(data);
|
||||
// 清空输入
|
||||
newContent.value = '';
|
||||
newImages.value = [];
|
||||
newTags.value = [];
|
||||
// 触发事件通知侧边栏刷新
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('micro-published'));
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || '发布失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to publish:', error);
|
||||
alert('发布失败,请稍后重试');
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 触发图片上传
|
||||
function triggerImageUpload() {
|
||||
imageInput.value?.click();
|
||||
}
|
||||
|
||||
// 处理图片选择
|
||||
async function handleImageSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
uploadingImages.value = true;
|
||||
uploadProgress.value = 0;
|
||||
|
||||
// 模拟进度条:使用 1-e^(-x) 函数,约5秒完成
|
||||
const totalDuration = 5000; // 5秒
|
||||
const interval = 100; // 每100ms更新一次
|
||||
let elapsed = 0;
|
||||
|
||||
const progressTimer = setInterval(() => {
|
||||
elapsed += interval;
|
||||
// 1 - e^(-t/1000) 函数,t单位ms,除以1000让曲线更平滑
|
||||
const progress = (1 - Math.exp(-elapsed / 1500)) * 100;
|
||||
uploadProgress.value = Math.min(Math.round(progress), 95); // 最多到95%,真正完成后到100%
|
||||
}, interval);
|
||||
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('图片大小不能超过 5MB');
|
||||
continue;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('token', props.imageUploadToken);
|
||||
|
||||
const response = await fetch(props.imageUploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.result === 'success' && data.url) {
|
||||
newImages.value.push(data.url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
} finally {
|
||||
clearInterval(progressTimer);
|
||||
uploadProgress.value = 100;
|
||||
// 短暂显示100%后隐藏
|
||||
setTimeout(() => {
|
||||
uploadingImages.value = false;
|
||||
uploadProgress.value = 0;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 清空 input
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
// 移除图片
|
||||
function removeImage(index: number) {
|
||||
newImages.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// 添加标签
|
||||
function addTag() {
|
||||
const tag = tagInput.value.trim();
|
||||
if (tag && !newTags.value.includes(tag) && newTags.value.length < 5) {
|
||||
newTags.value.push(tag);
|
||||
tagInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 移除标签
|
||||
function removeTag(index: number) {
|
||||
newTags.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// 插入 emoji
|
||||
function insertEmoji(emoji: string) {
|
||||
if (textareaRef.value) {
|
||||
const start = textareaRef.value.selectionStart;
|
||||
const end = textareaRef.value.selectionEnd;
|
||||
const text = newContent.value;
|
||||
newContent.value = text.substring(0, start) + emoji + text.substring(end);
|
||||
showEmojiPicker.value = false;
|
||||
// 恢复焦点
|
||||
textareaRef.value.focus();
|
||||
textareaRef.value.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||
} else {
|
||||
newContent.value += emoji;
|
||||
showEmojiPicker.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析图片 JSON
|
||||
function parseImages(imagesJson: string): string[] {
|
||||
if (!imagesJson) return [];
|
||||
try {
|
||||
return JSON.parse(imagesJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 解析标签 JSON
|
||||
function parseTags(tagsJson: string): string[] {
|
||||
if (!tagsJson) return [];
|
||||
try {
|
||||
return JSON.parse(tagsJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 处理标签点击
|
||||
function handleTagClick(tag: string) {
|
||||
// 触发事件通知侧边栏和父组件
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('tag-filter', { detail: tag }));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理点赞更新
|
||||
function handleLikeUpdate(id: number, liked: boolean) {
|
||||
const micro = micros.value.find(m => m.id === id);
|
||||
if (micro) {
|
||||
micro.is_liked = liked;
|
||||
micro.like_count += liked ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMicros();
|
||||
|
||||
// 点击外部关闭 emoji 选择器和标签输入
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// 关闭 emoji 选择器
|
||||
if (!target.closest('.relative') || !target.closest('button')) {
|
||||
showEmojiPicker.value = false;
|
||||
}
|
||||
|
||||
// 关闭标签输入面板
|
||||
if (tagInputRef.value && !tagInputRef.value.contains(target)) {
|
||||
showTagInput.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听标签筛选事件
|
||||
window.addEventListener('tag-filter', ((e: CustomEvent) => {
|
||||
currentTag.value = e.detail || '';
|
||||
loadMicros(1);
|
||||
}) as EventListener);
|
||||
});
|
||||
</script>
|
||||
352
src/components/MicroPost.vue
Normal file
352
src/components/MicroPost.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<article class="micro-post group">
|
||||
<!-- 用户头像 -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-primary-400 to-purple-500 flex items-center justify-center text-white font-bold text-lg">
|
||||
{{ getInitial(author) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 作者信息 -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="font-semibold text-foreground">{{ author }}</span>
|
||||
<span class="text-sm text-foreground/40">{{ formatTime(createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="micro-content prose prose-sm dark:prose-invert max-w-none mb-3" v-html="renderedContent"></div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div v-if="tags && tags.length > 0" class="flex flex-wrap gap-1 mb-3">
|
||||
<span
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
class="inline-block px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full cursor-pointer hover:bg-primary-200 dark:hover:bg-primary-800 transition-colors"
|
||||
@click="$emit('tag-click', tag)"
|
||||
>
|
||||
#{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 图片网格 -->
|
||||
<div v-if="images && images.length > 0" class="image-grid mb-3">
|
||||
<div
|
||||
:class="[
|
||||
'grid gap-2',
|
||||
images.length === 1 ? 'grid-cols-1 max-w-md' : '',
|
||||
images.length === 2 ? 'grid-cols-2 max-w-lg' : '',
|
||||
images.length >= 3 ? 'grid-cols-3 max-w-xl' : ''
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-for="(image, index) in images.slice(0, 9)"
|
||||
:key="index"
|
||||
class="relative overflow-hidden rounded-lg cursor-pointer group/img"
|
||||
:class="images.length === 1 ? 'aspect-video' : 'aspect-square'"
|
||||
@click="previewImageAt(index)"
|
||||
>
|
||||
<img
|
||||
:src="image"
|
||||
:alt="`图片 ${index + 1}`"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover/img:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-if="index === 8 && images.length > 9" class="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<span class="text-white text-xl font-bold">+{{ images.length - 9 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="flex items-center gap-6 text-foreground/50">
|
||||
<!-- 点赞 -->
|
||||
<button
|
||||
@click="toggleLike"
|
||||
class="flex items-center gap-1.5 hover:text-red-500 transition-colors"
|
||||
:class="{ 'text-red-500': liked }"
|
||||
>
|
||||
<svg class="w-5 h-5" :fill="liked ? 'currentColor' : 'none'" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 class="text-sm">{{ likeCount || '' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 评论 -->
|
||||
<button
|
||||
@click="showComments = !showComments"
|
||||
class="flex items-center gap-1.5 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span class="text-sm">{{ commentCount || '' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 分享 -->
|
||||
<button
|
||||
@click="sharePost"
|
||||
class="flex items-center gap-1.5 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<span class="text-sm">分享</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 评论区 -->
|
||||
<Transition name="slide">
|
||||
<div v-if="showComments" class="mt-4 pt-4 border-t border-border">
|
||||
<MicroCommentSection
|
||||
:micro-id="id"
|
||||
:api-base-url="apiBaseUrl"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="lightbox">
|
||||
<div
|
||||
v-if="previewIndex !== null"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
||||
@click="closePreview"
|
||||
>
|
||||
<button
|
||||
class="absolute top-4 right-4 p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
|
||||
@click="closePreview"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="images && images.length > 1"
|
||||
class="absolute left-4 p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
|
||||
@click.stop="prevImage"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img
|
||||
v-if="images && previewIndex !== null"
|
||||
:src="images[previewIndex]"
|
||||
class="max-w-[90vw] max-h-[90vh] rounded-lg shadow-2xl"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="images && images.length > 1"
|
||||
class="absolute right-4 p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
|
||||
@click.stop="nextImage"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="images && images.length > 1" class="absolute bottom-4 text-white/60 text-sm">
|
||||
{{ previewIndex + 1 }} / {{ images.length }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import MicroCommentSection from './MicroCommentSection.vue';
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
author?: string;
|
||||
content: string;
|
||||
images?: string[];
|
||||
tags?: string[];
|
||||
createdAt: string;
|
||||
likeCount?: number;
|
||||
commentCount?: number;
|
||||
isLiked?: boolean;
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
author: '博主',
|
||||
likeCount: 0,
|
||||
commentCount: 0,
|
||||
isLiked: false,
|
||||
tags: () => [],
|
||||
apiBaseUrl: 'http://localhost:8080/api',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'like', id: number, liked: boolean): void;
|
||||
(e: 'share', id: number): void;
|
||||
(e: 'tag-click', tag: string): void;
|
||||
}>();
|
||||
|
||||
// 状态
|
||||
const liked = ref(props.isLiked);
|
||||
const likeCount = ref(props.likeCount);
|
||||
const showComments = ref(false);
|
||||
|
||||
// 图片预览
|
||||
const previewIndex = ref<number | null>(null);
|
||||
|
||||
// 渲染 Markdown 内容
|
||||
const renderedContent = computed(() => {
|
||||
try {
|
||||
return marked.parse(props.content, { breaks: true, gfm: true }) as string;
|
||||
} catch {
|
||||
return props.content;
|
||||
}
|
||||
});
|
||||
|
||||
// 获取头像首字母
|
||||
function getInitial(name: string): string {
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(dateStr: string): 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', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
// 切换点赞
|
||||
async function toggleLike() {
|
||||
try {
|
||||
const response = await fetch(`${props.apiBaseUrl}/micro/${props.id}/like`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
liked.value = data.liked;
|
||||
likeCount.value = data.like_count;
|
||||
emit('like', props.id, data.liked);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle like:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 分享
|
||||
function sharePost() {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: '分享微语',
|
||||
text: props.content.slice(0, 100),
|
||||
url: window.location.href,
|
||||
});
|
||||
} else {
|
||||
// 复制链接
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert('链接已复制到剪贴板');
|
||||
}
|
||||
emit('share', props.id);
|
||||
}
|
||||
|
||||
// 获取认证头
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const token = localStorage.getItem('token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// 图片预览
|
||||
function previewImageAt(index: number) {
|
||||
previewIndex.value = index;
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewIndex.value = null;
|
||||
}
|
||||
|
||||
function prevImage() {
|
||||
if (props.images && previewIndex.value !== null) {
|
||||
previewIndex.value = (previewIndex.value - 1 + props.images.length) % props.images.length;
|
||||
}
|
||||
}
|
||||
|
||||
function nextImage() {
|
||||
if (props.images && previewIndex.value !== null) {
|
||||
previewIndex.value = (previewIndex.value + 1) % props.images.length;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.micro-post {
|
||||
@apply flex gap-4 p-4 bg-background border border-border rounded-xl transition-shadow duration-200;
|
||||
}
|
||||
|
||||
.micro-post:hover {
|
||||
@apply shadow-md;
|
||||
}
|
||||
|
||||
.micro-content :deep(p) {
|
||||
@apply mb-2 last:mb-0;
|
||||
}
|
||||
|
||||
.micro-content :deep(a) {
|
||||
@apply text-primary-500 hover:underline;
|
||||
}
|
||||
|
||||
.micro-content :deep(code) {
|
||||
@apply px-1.5 py-0.5 bg-muted rounded text-sm font-mono;
|
||||
}
|
||||
|
||||
.image-grid img {
|
||||
@apply transition-transform duration-300;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.lightbox-enter-active,
|
||||
.lightbox-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.lightbox-enter-from,
|
||||
.lightbox-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
191
src/components/MicroSidebar.vue
Normal file
191
src/components/MicroSidebar.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 热力图 -->
|
||||
<HeatmapCalendar
|
||||
title="发布活动"
|
||||
:data="heatmapData"
|
||||
:year="year"
|
||||
color-scheme="green"
|
||||
/>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold mb-4">统计</h3>
|
||||
<div v-if="loading" class="text-center py-4">
|
||||
<div class="animate-spin w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full mx-auto"></div>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center p-4 bg-muted/50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-primary-500">{{ stats.total_micros || 0 }}</div>
|
||||
<div class="text-sm text-foreground/60">总微语</div>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-muted/50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-primary-500">{{ stats.month_micros || 0 }}</div>
|
||||
<div class="text-sm text-foreground/60">本月发布</div>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-muted/50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-primary-500">{{ formatNumber(stats.total_likes) }}</div>
|
||||
<div class="text-sm text-foreground/60">总点赞</div>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-muted/50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-primary-500">{{ stats.total_comments || 0 }}</div>
|
||||
<div class="text-sm text-foreground/60">总评论</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签云 -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">热门标签</h3>
|
||||
<button
|
||||
v-if="currentTag"
|
||||
@click="clearTagFilter"
|
||||
class="text-xs text-primary-500 hover:text-primary-600"
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="loadingTags" class="text-center py-2">
|
||||
<div class="animate-spin w-5 h-5 border-2 border-primary-500 border-t-transparent rounded-full mx-auto"></div>
|
||||
</div>
|
||||
<div v-else-if="tags.length === 0" class="text-center py-2 text-foreground/40 text-sm">
|
||||
暂无标签
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="tag in tags"
|
||||
:key="tag.name"
|
||||
class="inline-block px-2 py-1 text-xs rounded-full cursor-pointer transition-colors"
|
||||
:class="currentTag === tag.name
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-800'"
|
||||
@click="filterByTag(tag.name)"
|
||||
>
|
||||
#{{ tag.name }} ({{ tag.count }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import HeatmapCalendar from './HeatmapCalendar.vue';
|
||||
|
||||
interface Props {
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
apiBaseUrl: 'http://localhost:8080/api',
|
||||
});
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const loading = ref(true);
|
||||
const loadingTags = ref(true);
|
||||
const heatmapData = ref<Record<string, number>>({});
|
||||
const stats = ref({
|
||||
total_micros: 0,
|
||||
month_micros: 0,
|
||||
total_likes: 0,
|
||||
total_comments: 0,
|
||||
});
|
||||
const tags = ref<{ name: string; count: number }[]>([]);
|
||||
const currentTag = ref('');
|
||||
|
||||
// 格式化数字
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return String(num);
|
||||
}
|
||||
|
||||
// 加载热力图数据
|
||||
async function loadHeatmap() {
|
||||
try {
|
||||
const response = await fetch(`${props.apiBaseUrl}/micro/heatmap?year=${year}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
heatmapData.value = data.data || {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load heatmap:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch(`${props.apiBaseUrl}/micro/stats`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
stats.value = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载标签
|
||||
async function loadTags() {
|
||||
try {
|
||||
const response = await fetch(`${props.apiBaseUrl}/micro/tags`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
tags.value = data.tags || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
} finally {
|
||||
loadingTags.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 按标签筛选
|
||||
function filterByTag(tag: string) {
|
||||
currentTag.value = tag;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('tag-filter', { detail: tag }));
|
||||
}
|
||||
}
|
||||
|
||||
// 清除筛选
|
||||
function clearTagFilter() {
|
||||
currentTag.value = '';
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('tag-filter', { detail: '' }));
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新所有数据
|
||||
function refresh() {
|
||||
loadHeatmap();
|
||||
loadStats();
|
||||
loadTags();
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({ refresh });
|
||||
|
||||
onMounted(() => {
|
||||
loadHeatmap();
|
||||
loadStats();
|
||||
loadTags();
|
||||
|
||||
// 监听刷新事件
|
||||
window.addEventListener('refresh-sidebar', refresh);
|
||||
|
||||
// 监听标签筛选事件(同步当前选中状态)
|
||||
window.addEventListener('tag-filter', ((e: CustomEvent) => {
|
||||
currentTag.value = e.detail || '';
|
||||
}) as EventListener);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('refresh-sidebar', refresh);
|
||||
});
|
||||
</script>
|
||||
@@ -112,6 +112,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>
|
||||
@@ -158,6 +159,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>
|
||||
|
||||
44
src/pages/micro.astro
Normal file
44
src/pages/micro.astro
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import MicroFeed from '../components/MicroFeed.vue';
|
||||
import MicroSidebar from '../components/MicroSidebar.vue';
|
||||
|
||||
// API 基础 URL
|
||||
const API_BASE = import.meta.env.PUBLIC_API_BASE || 'http://localhost:8080';
|
||||
---
|
||||
|
||||
<BaseLayout title="微语 - NovaBlog" description="分享生活点滴,记录灵感瞬间">
|
||||
<div class="content-width py-8">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2">微语</h1>
|
||||
<p class="text-foreground/60">分享生活点滴,记录灵感瞬间</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- 左侧:发布框和动态列表 -->
|
||||
<div class="lg:col-span-2">
|
||||
<MicroFeed
|
||||
client:load
|
||||
apiBaseUrl={`${API_BASE}/api`}
|
||||
onPublished="handlePublished"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:热力图和统计 -->
|
||||
<MicroSidebar
|
||||
client:load
|
||||
apiBaseUrl={`${API_BASE}/api`}
|
||||
ref="sidebarRef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
// 通过自定义事件实现组件间通信
|
||||
window.addEventListener('micro-published', () => {
|
||||
// 触发侧边栏刷新
|
||||
window.dispatchEvent(new CustomEvent('refresh-sidebar'));
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user