refactor: remove micro posts (微语) feature entirely

Remove the micro posts feature from the codebase including:
- Backend: API routes, handlers, and database models (MicroPost, MicroPostLike)
- Frontend: React components (Heatmap, MicroComposer, MicroList, MicroPage)
- Pages: micro.astro page and navigation links
- Documentation: API docs and user guide sections

This simplifies the application by removing a feature that is no longer needed.

BREAKING CHANGE: All micro posts related API endpoints (/api/micros) are removed.
Existing micro posts data will not be accessible after this change.
This commit is contained in:
Jiao77
2026-03-04 16:49:27 +08:00
parent 7ce99f9294
commit f4d5e4b3dc
13 changed files with 16 additions and 1402 deletions

1
.gitignore vendored
View File

@@ -98,6 +98,7 @@ tmp/
temp/
*.tmp
*.temp
reference
# Backup files
*.bak

View File

@@ -79,10 +79,8 @@ NovaBlog/
│ │ ├── FlipCard.tsx
│ │ ├── ParticleBackground.tsx
│ │ ├── TypewriterText.tsx
│ │ ├── Heatmap.tsx # 热力图组件
│ │ ── MicroList.tsx # 微语列表
│ │ ├── MicroComposer.tsx # 发布微语
│ │ └── MicroPage.tsx # 微语页面容器
│ │ ├── MergeTable.tsx
│ │ ── MathFlipCard.tsx
│ ├── content/ # 内容集合
│ │ ├── config.ts # 内容配置
│ │ └── blog/ # 博客文章
@@ -92,7 +90,6 @@ NovaBlog/
│ ├── pages/ # 页面路由
│ │ ├── index.astro # 首页
│ │ ├── login.astro # 登录页
│ │ ├── micro.astro # 微语页
│ │ ├── blog/ # 博客相关页面
│ │ ├── tags/ # 标签页面
│ │ └── categories/ # 分类页面
@@ -111,8 +108,7 @@ NovaBlog/
│ │ ├── handlers/ # HTTP 处理器
│ │ │ ├── auth.go # 认证处理
│ │ │ ├── comment.go # 评论处理
│ │ │ ── like.go # 点赞处理
│ │ │ └── micro.go # 微语处理
│ │ │ ── like.go # 点赞处理
│ │ ├── middleware/ # 中间件
│ │ ├── models/ # 数据模型
│ │ └── utils/ # 工具函数
@@ -330,171 +326,6 @@ Authorization: Bearer <token> // 可选
**响应**: 同切换接口
### 微语接口
#### 获取微语列表
```http
GET /api/micros?page=1&page_size=20&user_id=1
Authorization: Bearer <token> //
```
**参数**:
- `page` (可选): 页码,默认 1
- `page_size` (可选): 每页数量,默认 20最大 50
- `user_id` (可选): 指定用户的微语
**响应**:
```json
{
"data": [
{
"id": 1,
"content": "今天天气真好!",
"images": "[]",
"tags": "[\"生活\", \"日常\"]",
"is_public": true,
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z",
"user": {
"id": 1,
"username": "testuser",
"nickname": "测试用户",
"avatar": "https://..."
},
"like_count": 5,
"is_liked": false
}
],
"pagination": {
"page": 1,
"page_size": 20,
"total": 100,
"total_page": 5
}
}
```
#### 获取单条微语
```http
GET /api/micros/:id
Authorization: Bearer <token> //
```
#### 发布微语
```http
POST /api/micros
Authorization: Bearer <token>
Content-Type: application/json
{
"content": "string", // 2000
"images": ["url1", "url2"], // URL
"tags": ["tag1", "tag2"], //
"is_public": true // true
}
```
**响应**:
```json
{
"id": 1,
"content": "今天天气真好!",
"images": "[]",
"tags": "[\"生活\"]",
"is_public": true,
"created_at": "2024-01-15T10:00:00Z",
"user": {...},
"like_count": 0,
"is_liked": false
}
```
#### 更新微语
```http
PUT /api/micros/:id
Authorization: Bearer <token>
Content-Type: application/json
{
"content": "string",
"images": ["url1"],
"tags": ["tag1"],
"is_public": true
}
```
**权限**: 仅作者可修改
#### 删除微语
```http
DELETE /api/micros/:id
Authorization: Bearer <token>
```
**权限**: 作者或管理员可删除
#### 点赞/取消点赞微语
```http
POST /api/micros/:id/like
Authorization: Bearer <token>
```
**响应**:
```json
{
"liked": true,
"message": "点赞成功"
}
```
#### 获取热力图数据
```http
GET /api/micros/heatmap?year=2024&user_id=1
```
**参数**:
- `year` (可选): 年份,默认当前年
- `user_id` (可选): 指定用户
**响应**:
```json
[
{ "date": "2024-01-15", "count": 3 },
{ "date": "2024-01-16", "count": 1 },
{ "date": "2024-01-20", "count": 5 }
]
```
#### 获取统计数据
```http
GET /api/micros/stats?user_id=1
```
**响应**:
```json
{
"total_micros": 150,
"total_users": 25,
"top_users": [
{
"user_id": 1,
"username": "admin",
"nickname": "管理员",
"avatar": "...",
"post_count": 45
}
]
}
```
### 错误响应格式
```json
@@ -597,46 +428,6 @@ CREATE TABLE post_meta (
CREATE INDEX idx_post_meta_post_id ON post_meta(post_id);
```
### micro_posts 表
```sql
CREATE TABLE micro_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME, -- 软删除
user_id INTEGER NOT NULL, -- 关联 users.id
content TEXT NOT NULL, -- 微语内容,最多 2000 字
images TEXT, -- JSON 数组存储图片 URL
tags TEXT, -- JSON 数组存储标签
is_public BOOLEAN DEFAULT 1, -- 是否公开
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_micro_posts_user_id ON micro_posts(user_id);
CREATE INDEX idx_micro_posts_deleted_at ON micro_posts(deleted_at);
CREATE INDEX idx_micro_posts_created_at ON micro_posts(created_at);
```
### micro_post_likes 表
```sql
CREATE TABLE micro_post_likes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
micro_post_id INTEGER NOT NULL, -- 关联 micro_posts.id
user_id INTEGER NOT NULL, -- 关联 users.id
FOREIGN KEY (micro_post_id) REFERENCES micro_posts(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 防止同一用户重复点赞同一条微语
CREATE UNIQUE INDEX idx_micropost_user ON micro_post_likes(micro_post_id, user_id);
CREATE INDEX idx_micropost_likes_user_id ON micro_post_likes(user_id);
```
---
## 前端组件开发

View File

@@ -12,11 +12,10 @@
4. [React 动效组件](#react-动效组件)
5. [动效 HTML 块](#动效-html-块)
6. [评论系统](#评论系统)
7. [微语功能](#微语功能)
8. [用户注册与登录](#用户注册与登录)
9. [主题定制](#主题定制)
10. [附件管理](#附件管理)
11. [常见问题](#常见问题)
7. [用户注册与登录](#用户注册与登录)
8. [主题定制](#主题定制)
9. [附件管理](#附件管理)
10. [常见问题](#常见问题)
---
@@ -568,64 +567,6 @@ NovaBlog 内置评论系统,支持多级嵌套回复和 Markdown 语法。
---
## 微语功能
微语是一个轻量级的分享空间,让你可以随时记录生活中的点滴、灵感与感悟。类似于社交媒体的动态功能,所有注册用户都可以发布。
### 访问微语
点击导航栏中的"微语"链接,或访问 `/micro` 页面。
### 发布微语
1. 登录你的账号
2. 在微语页面顶部的输入框中输入内容
3. 可选:添加标签(用逗号或空格分隔)
4. 选择是否公开可见
5. 点击"发布"按钮
**内容限制**
- 单条微语最多 2000 字
- 支持多标签
### 微语列表
- 所有公开的微语都会显示在列表中
- 支持分页加载更多
- 显示发布者头像、昵称和发布时间
- 支持点赞功能
### 热力图
微语页面右侧显示 GitHub 风格的热力图,展示一年内的发布活动:
- **颜色深浅**:表示当天发布的微语数量
- **悬停查看**:鼠标悬停可查看具体日期和数量
- **年度统计**:显示全年发布的微语总数
### 点赞微语
1. 登录后可以给微语点赞
2. 点击心形图标即可点赞或取消点赞
3. 点赞数会实时更新
### 删除微语
- 用户可以删除自己发布的微语
- 管理员可以删除任何微语
### 微语与博客的区别
| 特性 | 博客文章 | 微语 |
|------|----------|------|
| 内容长度 | 无限制 | 最多 2000 字 |
| 格式支持 | Markdown + MDX | 纯文本 |
| 发布权限 | 管理员 | 所有注册用户 |
| 适用场景 | 长篇教程、技术文章 | 随手记录、灵感分享 |
| 互动功能 | 评论 | 点赞 |
---
## 用户注册与登录
### 注册账号

View File

@@ -33,7 +33,6 @@ func main() {
authHandler := handlers.NewAuthHandler(jwtManager)
commentHandler := handlers.NewCommentHandler()
likeHandler := handlers.NewLikeHandler()
microHandler := handlers.NewMicroHandler()
// 创建路由
r := gin.New()
@@ -55,7 +54,7 @@ func main() {
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"status": "ok",
"message": "NovaBlog API is running",
})
})
@@ -71,12 +70,6 @@ func main() {
api.GET("/likes", likeHandler.GetLikeStatus)
api.POST("/likes", likeHandler.ToggleLike) // 允许访客点赞(基于 IP Hash
// 微语公开接口
api.GET("/micros", microHandler.GetMicros)
api.GET("/micros/stats", microHandler.GetStats)
api.GET("/micros/heatmap", microHandler.GetHeatmap)
api.GET("/micros/:id", microHandler.GetMicro)
// 需要认证的接口
authGroup := api.Group("")
authGroup.Use(middleware.AuthMiddleware(jwtManager))
@@ -88,12 +81,6 @@ func main() {
// 评论相关(需要登录才能评论)
authGroup.POST("/comments", commentHandler.CreateComment)
authGroup.DELETE("/comments/:id", commentHandler.DeleteComment)
// 微语相关(需要登录)
authGroup.POST("/micros", microHandler.CreateMicro)
authGroup.PUT("/micros/:id", microHandler.UpdateMicro)
authGroup.DELETE("/micros/:id", microHandler.DeleteMicro)
authGroup.POST("/micros/:id/like", microHandler.ToggleLike)
}
// 管理员接口
@@ -115,4 +102,4 @@ func main() {
if err := r.Run(":" + port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
}

View File

@@ -54,8 +54,6 @@ func autoMigrate() error {
&models.Like{},
&models.LikeCount{},
&models.PostMeta{},
&models.MicroPost{},
&models.MicroPostLike{},
)
}

View File

@@ -1,411 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/novablog/server/internal/database"
"github.com/novablog/server/internal/middleware"
"github.com/novablog/server/internal/models"
"gorm.io/gorm"
)
func mustMarshal(v interface{}) []byte {
data, _ := json.Marshal(v)
return data
}
type MicroHandler struct{}
func NewMicroHandler() *MicroHandler {
return &MicroHandler{}
}
type CreateMicroRequest struct {
Content string `json:"content" binding:"required,max=2000"`
Images []string `json:"images"`
Tags []string `json:"tags"`
IsPublic bool `json:"is_public"`
}
type UpdateMicroRequest struct {
Content string `json:"content" binding:"required,max=2000"`
Images []string `json:"images"`
Tags []string `json:"tags"`
IsPublic bool `json:"is_public"`
}
type MicroResponse struct {
models.MicroPost
LikeCount int `json:"like_count"`
IsLiked bool `json:"is_liked"`
}
func (h *MicroHandler) CreateMicro(c *gin.Context) {
userID, ok := middleware.GetUserID(c)
if !ok || userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后再发布"})
return
}
var req CreateMicroRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
imagesJSON := "[]"
if len(req.Images) > 0 {
imagesJSON = string(mustMarshal(req.Images))
}
tagsJSON := "[]"
if len(req.Tags) > 0 {
tagsJSON = string(mustMarshal(req.Tags))
}
micro := models.MicroPost{
UserID: userID,
Content: req.Content,
Images: imagesJSON,
Tags: tagsJSON,
IsPublic: req.IsPublic,
}
if err := database.DB.Create(&micro).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败"})
return
}
database.DB.Preload("User").First(&micro, micro.ID)
c.JSON(http.StatusCreated, MicroResponse{
MicroPost: micro,
LikeCount: 0,
IsLiked: false,
})
}
func (h *MicroHandler) GetMicros(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
userIDQuery := c.Query("user_id")
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 50 {
pageSize = 20
}
var micros []models.MicroPost
var total int64
query := database.DB.Model(&models.MicroPost{}).Where("is_public = ?", true)
if userIDQuery != "" {
query = query.Where("user_id = ?", userIDQuery)
}
query.Count(&total)
if err := query.
Preload("User").
Order("created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&micros).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取失败"})
return
}
currentUserID, _ := middleware.GetUserID(c)
responses := make([]MicroResponse, len(micros))
for i, micro := range micros {
var likeCount int64
database.DB.Model(&models.MicroPostLike{}).Where("micro_post_id = ?", micro.ID).Count(&likeCount)
isLiked := false
if currentUserID > 0 {
var count int64
database.DB.Model(&models.MicroPostLike{}).
Where("micro_post_id = ? AND user_id = ?", micro.ID, currentUserID).
Count(&count)
isLiked = count > 0
}
responses[i] = MicroResponse{
MicroPost: micro,
LikeCount: int(likeCount),
IsLiked: isLiked,
}
}
c.JSON(http.StatusOK, gin.H{
"data": responses,
"pagination": gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": (total + int64(pageSize) - 1) / int64(pageSize),
},
})
}
func (h *MicroHandler) GetMicro(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var micro models.MicroPost
if err := database.DB.Preload("User").First(&micro, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
var likeCount int64
database.DB.Model(&models.MicroPostLike{}).Where("micro_post_id = ?", micro.ID).Count(&likeCount)
currentUserID, _ := middleware.GetUserID(c)
isLiked := false
if currentUserID > 0 {
var count int64
database.DB.Model(&models.MicroPostLike{}).
Where("micro_post_id = ? AND user_id = ?", micro.ID, currentUserID).
Count(&count)
isLiked = count > 0
}
c.JSON(http.StatusOK, MicroResponse{
MicroPost: micro,
LikeCount: int(likeCount),
IsLiked: isLiked,
})
}
func (h *MicroHandler) UpdateMicro(c *gin.Context) {
userID, _ := middleware.GetUserID(c)
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var micro models.MicroPost
if err := database.DB.First(&micro, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
if micro.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "无权修改"})
return
}
var req UpdateMicroRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
imagesJSON := "[]"
if len(req.Images) > 0 {
imagesJSON = string(mustMarshal(req.Images))
}
tagsJSON := "[]"
if len(req.Tags) > 0 {
tagsJSON = string(mustMarshal(req.Tags))
}
updates := map[string]interface{}{
"content": req.Content,
"images": imagesJSON,
"tags": tagsJSON,
"is_public": req.IsPublic,
}
if err := database.DB.Model(&micro).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
return
}
database.DB.Preload("User").First(&micro, micro.ID)
c.JSON(http.StatusOK, micro)
}
func (h *MicroHandler) DeleteMicro(c *gin.Context) {
userID, _ := middleware.GetUserID(c)
role, _ := c.Get("role")
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var micro models.MicroPost
if err := database.DB.First(&micro, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
if micro.UserID != userID && role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除"})
return
}
if err := database.DB.Delete(&micro).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
func (h *MicroHandler) ToggleLike(c *gin.Context) {
userID, ok := middleware.GetUserID(c)
if !ok || userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后再点赞"})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var micro models.MicroPost
if err := database.DB.First(&micro, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
var existingLike models.MicroPostLike
result := database.DB.Where("micro_post_id = ? AND user_id = ?", id, userID).First(&existingLike)
if result.Error == nil {
database.DB.Delete(&existingLike)
c.JSON(http.StatusOK, gin.H{"liked": false, "message": "取消点赞"})
return
}
like := models.MicroPostLike{
MicroPostID: uint(id),
UserID: userID,
}
if err := database.DB.Create(&like).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "点赞失败"})
return
}
c.JSON(http.StatusOK, gin.H{"liked": true, "message": "点赞成功"})
}
func (h *MicroHandler) GetHeatmap(c *gin.Context) {
userIDQuery := c.Query("user_id")
yearStr := c.DefaultQuery("year", strconv.Itoa(time.Now().Year()))
year, _ := strconv.Atoi(yearStr)
query := database.DB.Model(&models.MicroPost{}).
Select("DATE(created_at) as date, COUNT(*) as count").
Where("is_public = ?", true).
Group("DATE(created_at)")
if userIDQuery != "" {
query = query.Where("user_id = ?", userIDQuery)
}
if year > 0 {
startDate := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
endDate := time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC)
query = query.Where("created_at >= ? AND created_at < ?", startDate, endDate)
}
var results []struct {
Date string `json:"date"`
Count int `json:"count"`
}
if err := query.Scan(&results).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取失败"})
return
}
c.JSON(http.StatusOK, results)
}
func (h *MicroHandler) GetStats(c *gin.Context) {
userIDQuery := c.Query("user_id")
var totalMicros int64
var totalUsers int64
query := database.DB.Model(&models.MicroPost{}).Where("is_public = ?", true)
if userIDQuery != "" {
query = query.Where("user_id = ?", userIDQuery)
}
query.Count(&totalMicros)
database.DB.Model(&models.User{}).Count(&totalUsers)
var topUsers []struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
PostCount int `json:"post_count"`
}
database.DB.Model(&models.MicroPost{}).
Select("user_id, COUNT(*) as post_count").
Where("is_public = ?", true).
Group("user_id").
Order("post_count DESC").
Limit(10).
Scan(&topUsers)
for i := range topUsers {
var user models.User
if err := database.DB.First(&user, topUsers[i].UserID).Error; err == nil {
topUsers[i].Username = user.Username
topUsers[i].Nickname = user.Nickname
topUsers[i].Avatar = user.Avatar
}
}
c.JSON(http.StatusOK, gin.H{
"total_micros": totalMicros,
"total_users": totalUsers,
"top_users": topUsers,
})
}

View File

@@ -55,32 +55,10 @@ type LikeCount struct {
// PostMeta 文章元数据(可选,用于存储文章额外信息)
type PostMeta struct {
ID uint `json:"id" gorm:"primaryKey"`
PostID string `json:"post_id" gorm:"uniqueIndex;size:100;not null"`
ViewCount int `json:"view_count" gorm:"default:0"`
LikeCount int `json:"like_count" gorm:"default:0"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id" gorm:"primaryKey"`
PostID string `json:"post_id" gorm:"uniqueIndex;size:100;not null"`
ViewCount int `json:"view_count" gorm:"default:0"`
LikeCount int `json:"like_count" gorm:"default:0"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MicroPost 微语模型
type MicroPost struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
UserID uint `json:"user_id" gorm:"index;not null"`
Content string `json:"content" gorm:"type:text;not null"`
Images string `json:"images" gorm:"type:text"` // JSON 数组存储图片 URL
Tags string `json:"tags" gorm:"type:text"` // JSON 数组存储标签
IsPublic bool `json:"is_public" gorm:"default:true"` // 是否公开
User User `json:"user" gorm:"foreignKey:UserID"`
}
// MicroPostLike 微语点赞
type MicroPostLike struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
MicroPostID uint `json:"micro_post_id" gorm:"uniqueIndex:idx_micropost_user;not null"`
UserID uint `json:"user_id" gorm:"uniqueIndex:idx_micropost_user;not null"`
}

View File

@@ -1,185 +0,0 @@
import { useState, useEffect } from 'react';
interface HeatmapData {
date: string;
count: number;
}
interface HeatmapProps {
userId?: string;
year?: number;
apiBaseUrl?: string;
}
const API_BASE = typeof window !== 'undefined'
? (import.meta.env.VITE_API_BASE || 'http://localhost:8080/api')
: 'http://localhost:8080/api';
const COLORS = [
'bg-primary-100 dark:bg-primary-900/30',
'bg-primary-200 dark:bg-primary-800/40',
'bg-primary-300 dark:bg-primary-700/50',
'bg-primary-400 dark:bg-primary-600/60',
'bg-primary-500 dark:bg-primary-500/70',
];
const MONTHS = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
const DAYS = ['日', '一', '二', '三', '四', '五', '六'];
export default function Heatmap({ userId, year = new Date().getFullYear(), apiBaseUrl }: HeatmapProps) {
const baseUrl = apiBaseUrl || API_BASE;
const [data, setData] = useState<HeatmapData[]>([]);
const [loading, setLoading] = useState(true);
const [hoveredCell, setHoveredCell] = useState<{ date: string; count: number } | null>(null);
useEffect(() => {
const fetchHeatmap = async () => {
try {
const params = new URLSearchParams({ year: year.toString() });
if (userId) params.append('user_id', userId);
const response = await fetch(`${baseUrl}/micros/heatmap?${params}`);
if (response.ok) {
const result = await response.json();
setData(result);
}
} catch (error) {
console.error('Failed to fetch heatmap:', error);
} finally {
setLoading(false);
}
};
fetchHeatmap();
}, [userId, year, baseUrl]);
const getDaysInYear = (year: number) => {
const days: Date[] = [];
const startDate = new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days;
};
const getCountForDate = (date: Date): number => {
const dateStr = date.toISOString().split('T')[0];
const item = data.find(d => d.date === dateStr);
return item ? item.count : 0;
};
const getColorClass = (count: number): string => {
if (count === 0) return 'bg-muted dark:bg-muted/50';
if (count <= 2) return COLORS[0];
if (count <= 4) return COLORS[1];
if (count <= 6) return COLORS[2];
if (count <= 8) return COLORS[3];
return COLORS[4];
};
const formatDisplayDate = (date: Date): string => {
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
};
const getWeeksInYear = (year: number) => {
const days = getDaysInYear(year);
const weeks: Date[][] = [];
let currentWeek: Date[] = [];
const firstDay = days[0];
const firstDayOfWeek = firstDay.getDay();
for (let i = 0; i < firstDayOfWeek; i++) {
currentWeek.push(new Date(year, 0, 1 - firstDayOfWeek + i));
}
days.forEach(day => {
if (day.getDay() === 0 && currentWeek.length > 0) {
weeks.push(currentWeek);
currentWeek = [];
}
currentWeek.push(day);
});
if (currentWeek.length > 0) {
weeks.push(currentWeek);
}
return weeks;
};
const weeks = getWeeksInYear(year);
const totalCount = data.reduce((sum, item) => sum + item.count, 0);
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-foreground">{year} </h3>
<span className="text-sm text-muted-foreground"> {totalCount} </span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
{COLORS.map((color, i) => (
<div key={i} className={`w-3 h-3 rounded-sm ${color}`}></div>
))}
<span></span>
</div>
</div>
<div className="overflow-x-auto">
<div className="inline-flex gap-1">
<div className="flex flex-col gap-1 mr-2 text-xs text-muted-foreground">
{DAYS.map((day, i) => (
<div key={i} className="h-3 flex items-center">{i % 2 === 1 ? day : ''}</div>
))}
</div>
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-1">
{DAYS.map((_, dayIndex) => {
const day = week[dayIndex];
if (!day || day.getFullYear() !== year) {
return <div key={dayIndex} className="w-3 h-3"></div>;
}
const count = getCountForDate(day);
const colorClass = getColorClass(count);
return (
<div
key={dayIndex}
className={`w-3 h-3 rounded-sm ${colorClass} cursor-pointer transition-transform hover:scale-125`}
onMouseEnter={() => setHoveredCell({ date: formatDisplayDate(day), count })}
onMouseLeave={() => setHoveredCell(null)}
/>
);
})}
</div>
))}
</div>
</div>
{hoveredCell && (
<div className="text-sm text-muted-foreground">
{hoveredCell.date}{hoveredCell.count}
</div>
)}
<div className="flex gap-4 text-xs text-muted-foreground">
{MONTHS.map((month, i) => (
<span key={i} className="flex-1 text-center">{month}</span>
))}
</div>
</div>
);
}

View File

@@ -1,132 +0,0 @@
import { useState } from 'react';
interface MicroComposerProps {
onClose?: () => void;
onSuccess?: () => void;
apiBaseUrl?: string;
}
const API_BASE = typeof window !== 'undefined'
? (import.meta.env.VITE_API_BASE || 'http://localhost:8080/api')
: 'http://localhost:8080/api';
export default function MicroComposer({ onClose, onSuccess, apiBaseUrl }: MicroComposerProps) {
const baseUrl = apiBaseUrl || API_BASE;
const [content, setContent] = useState('');
const [tags, setTags] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (!content.trim()) {
alert('请输入内容');
return;
}
const token = localStorage.getItem('token');
if (!token) {
alert('请登录后再发布');
return;
}
setSubmitting(true);
try {
const tagList = tags
.split(/[,\s]+/)
.map(t => t.trim())
.filter(t => t.length > 0);
const response = await fetch(`${baseUrl}/micros`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
content: content.trim(),
tags: tagList,
is_public: isPublic,
images: [],
}),
});
if (response.ok) {
setContent('');
setTags('');
setIsPublic(true);
onSuccess?.();
onClose?.();
} else {
const error = await response.json();
alert(error.error || '发布失败');
}
} catch (error) {
console.error('Failed to post:', error);
alert('发布失败,请重试');
} finally {
setSubmitting(false);
}
};
const remainingChars = 2000 - content.length;
return (
<div className="card">
<div className="mb-4">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="分享你的想法..."
className="input min-h-[120px] resize-none"
maxLength={2000}
/>
<div className="flex justify-end mt-1">
<span className={`text-xs ${remainingChars < 100 ? 'text-red-500' : 'text-muted-foreground'}`}>
{remainingChars}
</span>
</div>
</div>
<div className="mb-4">
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="标签(用逗号或空格分隔)"
className="input"
/>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
className="w-4 h-4 rounded border-border text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-muted-foreground"></span>
</label>
<div className="flex gap-2">
{onClose && (
<button
onClick={onClose}
className="btn-secondary"
disabled={submitting}
>
</button>
)}
<button
onClick={handleSubmit}
className="btn-primary"
disabled={submitting || !content.trim()}
>
{submitting ? '发布中...' : '发布'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,280 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
interface User {
id: number;
username: string;
nickname: string;
avatar: string;
}
interface MicroPost {
id: number;
content: string;
images: string;
tags: string;
is_public: boolean;
created_at: string;
updated_at: string;
user: User;
like_count: number;
is_liked: boolean;
}
interface MicroListProps {
userId?: string;
onOpenComposer?: () => void;
apiBaseUrl?: string;
}
const API_BASE = typeof window !== 'undefined'
? (import.meta.env.VITE_API_BASE || 'http://localhost:8080/api')
: 'http://localhost:8080/api';
export default function MicroList({ userId, onOpenComposer, apiBaseUrl }: MicroListProps) {
const baseUrl = apiBaseUrl || API_BASE;
const [micros, setMicros] = useState<MicroPost[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
const fetchMicros = useCallback(async (pageNum: number, append = false) => {
try {
const params = new URLSearchParams({
page: pageNum.toString(),
page_size: '10',
});
if (userId) params.append('user_id', userId);
const token = localStorage.getItem('token');
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${baseUrl}/micros?${params}`, { headers });
if (response.ok) {
const result = await response.json();
if (append) {
setMicros(prev => [...prev, ...result.data]);
} else {
setMicros(result.data);
}
setTotal(result.pagination.total);
setHasMore(result.pagination.page < result.pagination.total_page);
}
} catch (error) {
console.error('Failed to fetch micros:', error);
} finally {
setLoading(false);
}
}, [userId, baseUrl]);
useEffect(() => {
fetchMicros(1);
}, [fetchMicros]);
const loadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
fetchMicros(nextPage, true);
};
const handleLike = async (microId: number) => {
const token = localStorage.getItem('token');
if (!token) {
alert('请登录后再点赞');
return;
}
try {
const response = await fetch(`${baseUrl}/micros/${microId}/like`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const result = await response.json();
setMicros(prev => prev.map(m => {
if (m.id === microId) {
return {
...m,
is_liked: result.liked,
like_count: result.liked ? m.like_count + 1 : m.like_count - 1,
};
}
return m;
}));
}
} catch (error) {
console.error('Failed to like:', error);
}
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
if (hours < 24) return `${hours} 小时前`;
if (days < 7) return `${days} 天前`;
return date.toLocaleDateString('zh-CN');
};
const parseJSON = (str: string) => {
try {
return JSON.parse(str || '[]');
} catch {
return [];
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-foreground"></h2>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground"> {total} </span>
{onOpenComposer && (
<button
onClick={onOpenComposer}
className="btn-primary text-sm"
>
</button>
)}
</div>
</div>
{micros.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
</div>
) : (
<>
<div className="space-y-4">
{micros.map(micro => {
const images = parseJSON(micro.images);
const tags = parseJSON(micro.tags);
return (
<div key={micro.id} className="card">
<div className="flex gap-3">
<div className="flex-shrink-0">
{micro.user.avatar ? (
<img
src={micro.user.avatar}
alt={micro.user.nickname || micro.user.username}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-primary-500 flex items-center justify-center text-white font-semibold">
{(micro.user.nickname || micro.user.username).charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-foreground">
{micro.user.nickname || micro.user.username}
</span>
<span className="text-xs text-muted-foreground">
@{micro.user.username}
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">
{formatTime(micro.created_at)}
</span>
</div>
<div className="text-foreground whitespace-pre-wrap break-words mb-3">
{micro.content}
</div>
{images.length > 0 && (
<div className={`grid gap-2 mb-3 ${
images.length === 1 ? 'grid-cols-1' :
images.length === 2 ? 'grid-cols-2' :
'grid-cols-3'
}`}>
{images.map((img: string, i: number) => (
<img
key={i}
src={img}
alt={`图片 ${i + 1}`}
className="rounded-lg object-cover w-full aspect-square"
/>
))}
</div>
)}
{tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{tags.map((tag: string, i: number) => (
<span key={i} className="tag text-xs">
#{tag}
</span>
))}
</div>
)}
<div className="flex items-center gap-6 text-muted-foreground">
<button
onClick={() => handleLike(micro.id)}
className={`flex items-center gap-1 transition-colors ${
micro.is_liked ? 'text-red-500' : 'hover:text-red-500'
}`}
>
<svg
className="w-5 h-5"
fill={micro.is_liked ? 'currentColor' : 'none'}
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<span className="text-sm">{micro.like_count || ''}</span>
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
{hasMore && (
<div className="text-center pt-4">
<button
onClick={loadMore}
className="btn-secondary"
>
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -1,59 +0,0 @@
import { useState, useCallback } from 'react';
import MicroList from './MicroList';
import MicroComposer from './MicroComposer';
import Heatmap from './Heatmap';
interface MicroPageProps {
apiBaseUrl?: string;
}
const API_BASE = typeof window !== 'undefined'
? (import.meta.env.VITE_API_BASE || 'http://localhost:8080/api')
: 'http://localhost:8080/api';
export default function MicroPage({ apiBaseUrl }: MicroPageProps) {
const baseUrl = apiBaseUrl || API_BASE;
const [refreshKey, setRefreshKey] = useState(0);
const [heatmapKey, setHeatmapKey] = useState(0);
const handlePostSuccess = useCallback(() => {
setRefreshKey(prev => prev + 1);
setHeatmapKey(prev => prev + 1);
}, []);
return (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
<div className="xl:col-span-2">
<div className="mb-8">
<MicroComposer
apiBaseUrl={baseUrl}
onSuccess={handlePostSuccess}
/>
</div>
<MicroList
key={refreshKey}
apiBaseUrl={baseUrl}
/>
</div>
<div className="xl:col-span-1">
<div className="sticky top-24 space-y-6">
<div className="card">
<Heatmap
key={heatmapKey}
apiBaseUrl={baseUrl}
/>
</div>
<div className="card">
<h3 className="text-lg font-semibold text-foreground mb-4"></h3>
<p className="text-sm text-muted-foreground leading-relaxed">
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -112,7 +112,6 @@ 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>
@@ -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">
<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>
@@ -189,7 +187,6 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
<h3 class="font-semibold text-lg mb-4">快速链接</h3>
<ul class="space-y-2 text-sm">
<li><a href="/blog" class="text-foreground/60 hover:text-primary-500 transition-colors">全部文章</a></li>
<li><a href="/micro" class="text-foreground/60 hover:text-primary-500 transition-colors">微语</a></li>
<li><a href="/tags" class="text-foreground/60 hover:text-primary-500 transition-colors">标签分类</a></li>
<li><a href="/about" class="text-foreground/60 hover:text-primary-500 transition-colors">关于我</a></li>
<li><a href="/rss.xml" class="text-foreground/60 hover:text-primary-500 transition-colors">RSS 订阅</a></li>

View File

@@ -1,12 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import MicroPage from '../components/react/MicroPage';
---
<BaseLayout title="微语 - NovaBlog" description="分享生活点滴,记录每一个精彩瞬间">
<div class="py-12">
<div class="content-width">
<MicroPage client:load />
</div>
</div>
</BaseLayout>