Merge pull request 'feat: 添加微语功能' (#10) from taishi-update into main

Reviewed-on: #10
This commit is contained in:
Jiao77
2026-03-05 08:59:14 +00:00
11 changed files with 2276 additions and 0 deletions

View File

@@ -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)
}
// 管理员接口

View File

@@ -54,6 +54,9 @@ func autoMigrate() error {
&models.Like{},
&models.LikeCount{},
&models.PostMeta{},
&models.Micro{},
&models.MicroLike{},
&models.MicroComment{},
)
}

View 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(&micro).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败"})
return
}
// 加载用户信息
database.DB.Preload("User").First(&micro, 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(&micros).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(&micro, 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(&micro, 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(&micro).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(&micro, 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(&micro).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(&micro).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(&micros)
// 统计标签使用次数
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(&micro, 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(&micro).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": "评论已删除"})
}

View File

@@ -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"`
}

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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
View 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>