revert 17ddd7fd65
revert Merge pull request 'feat: 添加微语功能' (#10) from taishi-update into main Reviewed-on: Jiao77/NovaBlog#10
This commit is contained in:
@@ -33,7 +33,6 @@ func main() {
|
|||||||
authHandler := handlers.NewAuthHandler(jwtManager)
|
authHandler := handlers.NewAuthHandler(jwtManager)
|
||||||
commentHandler := handlers.NewCommentHandler()
|
commentHandler := handlers.NewCommentHandler()
|
||||||
likeHandler := handlers.NewLikeHandler()
|
likeHandler := handlers.NewLikeHandler()
|
||||||
microHandler := handlers.NewMicroHandler()
|
|
||||||
|
|
||||||
// 创建路由
|
// 创建路由
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -71,15 +70,6 @@ func main() {
|
|||||||
api.GET("/likes", likeHandler.GetLikeStatus)
|
api.GET("/likes", likeHandler.GetLikeStatus)
|
||||||
api.POST("/likes", likeHandler.ToggleLike) // 允许访客点赞(基于 IP Hash)
|
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 := api.Group("")
|
||||||
authGroup.Use(middleware.AuthMiddleware(jwtManager))
|
authGroup.Use(middleware.AuthMiddleware(jwtManager))
|
||||||
@@ -91,14 +81,6 @@ func main() {
|
|||||||
// 评论相关(需要登录才能评论)
|
// 评论相关(需要登录才能评论)
|
||||||
authGroup.POST("/comments", commentHandler.CreateComment)
|
authGroup.POST("/comments", commentHandler.CreateComment)
|
||||||
authGroup.DELETE("/comments/:id", commentHandler.DeleteComment)
|
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,9 +54,6 @@ func autoMigrate() error {
|
|||||||
&models.Like{},
|
&models.Like{},
|
||||||
&models.LikeCount{},
|
&models.LikeCount{},
|
||||||
&models.PostMeta{},
|
&models.PostMeta{},
|
||||||
&models.Micro{},
|
|
||||||
&models.MicroLike{},
|
|
||||||
&models.MicroComment{},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,595 +0,0 @@
|
|||||||
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,41 +62,3 @@ type PostMeta struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
<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,7 +112,6 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
|
|||||||
<div class="hidden md:flex items-center gap-6">
|
<div class="hidden md:flex items-center gap-6">
|
||||||
<a href="/" class="text-foreground/70 hover:text-foreground transition-colors">首页</a>
|
<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="/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="/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="/tags" class="text-foreground/70 hover:text-foreground transition-colors">标签</a>
|
||||||
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors">关于</a>
|
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors">关于</a>
|
||||||
@@ -159,7 +158,6 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
|
|||||||
<div class="content-width py-4 flex flex-col gap-3">
|
<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="/" 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="/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="/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="/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>
|
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors py-2">关于</a>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
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