Compare commits
10 Commits
7ce99f9294
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57d73b9875 | ||
|
|
395ecd54b0 | ||
|
|
9e8a2c6f2b | ||
|
|
17ddd7fd65 | ||
|
|
0961bbd1b7 | ||
|
|
5986f116ec | ||
|
|
b17178b585 | ||
|
|
35aed42617 | ||
|
|
e095b2faca | ||
|
|
f4d5e4b3dc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -98,6 +98,7 @@ tmp/
|
|||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
reference
|
||||||
|
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "src/content/blog"]
|
||||||
|
path = src/content/blog
|
||||||
|
url = https://gitea.jiao77.cn/Jiao77/NovaBlog-Post.git
|
||||||
@@ -79,10 +79,8 @@ NovaBlog/
|
|||||||
│ │ ├── FlipCard.tsx
|
│ │ ├── FlipCard.tsx
|
||||||
│ │ ├── ParticleBackground.tsx
|
│ │ ├── ParticleBackground.tsx
|
||||||
│ │ ├── TypewriterText.tsx
|
│ │ ├── TypewriterText.tsx
|
||||||
│ │ ├── Heatmap.tsx # 热力图组件
|
│ │ ├── MergeTable.tsx
|
||||||
│ │ ├── MicroList.tsx # 微语列表
|
│ │ └── MathFlipCard.tsx
|
||||||
│ │ ├── MicroComposer.tsx # 发布微语
|
|
||||||
│ │ └── MicroPage.tsx # 微语页面容器
|
|
||||||
│ ├── content/ # 内容集合
|
│ ├── content/ # 内容集合
|
||||||
│ │ ├── config.ts # 内容配置
|
│ │ ├── config.ts # 内容配置
|
||||||
│ │ └── blog/ # 博客文章
|
│ │ └── blog/ # 博客文章
|
||||||
@@ -92,7 +90,6 @@ NovaBlog/
|
|||||||
│ ├── pages/ # 页面路由
|
│ ├── pages/ # 页面路由
|
||||||
│ │ ├── index.astro # 首页
|
│ │ ├── index.astro # 首页
|
||||||
│ │ ├── login.astro # 登录页
|
│ │ ├── login.astro # 登录页
|
||||||
│ │ ├── micro.astro # 微语页
|
|
||||||
│ │ ├── blog/ # 博客相关页面
|
│ │ ├── blog/ # 博客相关页面
|
||||||
│ │ ├── tags/ # 标签页面
|
│ │ ├── tags/ # 标签页面
|
||||||
│ │ └── categories/ # 分类页面
|
│ │ └── categories/ # 分类页面
|
||||||
@@ -111,8 +108,7 @@ NovaBlog/
|
|||||||
│ │ ├── handlers/ # HTTP 处理器
|
│ │ ├── handlers/ # HTTP 处理器
|
||||||
│ │ │ ├── auth.go # 认证处理
|
│ │ │ ├── auth.go # 认证处理
|
||||||
│ │ │ ├── comment.go # 评论处理
|
│ │ │ ├── comment.go # 评论处理
|
||||||
│ │ │ ├── like.go # 点赞处理
|
│ │ │ └── like.go # 点赞处理
|
||||||
│ │ │ └── micro.go # 微语处理
|
|
||||||
│ │ ├── middleware/ # 中间件
|
│ │ ├── middleware/ # 中间件
|
||||||
│ │ ├── models/ # 数据模型
|
│ │ ├── models/ # 数据模型
|
||||||
│ │ └── utils/ # 工具函数
|
│ │ └── 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
|
```json
|
||||||
@@ -597,46 +428,6 @@ CREATE TABLE post_meta (
|
|||||||
CREATE INDEX idx_post_meta_post_id ON post_meta(post_id);
|
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);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 前端组件开发
|
## 前端组件开发
|
||||||
|
|||||||
@@ -12,11 +12,10 @@
|
|||||||
4. [React 动效组件](#react-动效组件)
|
4. [React 动效组件](#react-动效组件)
|
||||||
5. [动效 HTML 块](#动效-html-块)
|
5. [动效 HTML 块](#动效-html-块)
|
||||||
6. [评论系统](#评论系统)
|
6. [评论系统](#评论系统)
|
||||||
7. [微语功能](#微语功能)
|
7. [用户注册与登录](#用户注册与登录)
|
||||||
8. [用户注册与登录](#用户注册与登录)
|
8. [主题定制](#主题定制)
|
||||||
9. [主题定制](#主题定制)
|
9. [附件管理](#附件管理)
|
||||||
10. [附件管理](#附件管理)
|
10. [常见问题](#常见问题)
|
||||||
11. [常见问题](#常见问题)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -568,64 +567,6 @@ NovaBlog 内置评论系统,支持多级嵌套回复和 Markdown 语法。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 微语功能
|
|
||||||
|
|
||||||
微语是一个轻量级的分享空间,让你可以随时记录生活中的点滴、灵感与感悟。类似于社交媒体的动态功能,所有注册用户都可以发布。
|
|
||||||
|
|
||||||
### 访问微语
|
|
||||||
|
|
||||||
点击导航栏中的"微语"链接,或访问 `/micro` 页面。
|
|
||||||
|
|
||||||
### 发布微语
|
|
||||||
|
|
||||||
1. 登录你的账号
|
|
||||||
2. 在微语页面顶部的输入框中输入内容
|
|
||||||
3. 可选:添加标签(用逗号或空格分隔)
|
|
||||||
4. 选择是否公开可见
|
|
||||||
5. 点击"发布"按钮
|
|
||||||
|
|
||||||
**内容限制**:
|
|
||||||
- 单条微语最多 2000 字
|
|
||||||
- 支持多标签
|
|
||||||
|
|
||||||
### 微语列表
|
|
||||||
|
|
||||||
- 所有公开的微语都会显示在列表中
|
|
||||||
- 支持分页加载更多
|
|
||||||
- 显示发布者头像、昵称和发布时间
|
|
||||||
- 支持点赞功能
|
|
||||||
|
|
||||||
### 热力图
|
|
||||||
|
|
||||||
微语页面右侧显示 GitHub 风格的热力图,展示一年内的发布活动:
|
|
||||||
|
|
||||||
- **颜色深浅**:表示当天发布的微语数量
|
|
||||||
- **悬停查看**:鼠标悬停可查看具体日期和数量
|
|
||||||
- **年度统计**:显示全年发布的微语总数
|
|
||||||
|
|
||||||
### 点赞微语
|
|
||||||
|
|
||||||
1. 登录后可以给微语点赞
|
|
||||||
2. 点击心形图标即可点赞或取消点赞
|
|
||||||
3. 点赞数会实时更新
|
|
||||||
|
|
||||||
### 删除微语
|
|
||||||
|
|
||||||
- 用户可以删除自己发布的微语
|
|
||||||
- 管理员可以删除任何微语
|
|
||||||
|
|
||||||
### 微语与博客的区别
|
|
||||||
|
|
||||||
| 特性 | 博客文章 | 微语 |
|
|
||||||
|------|----------|------|
|
|
||||||
| 内容长度 | 无限制 | 最多 2000 字 |
|
|
||||||
| 格式支持 | Markdown + MDX | 纯文本 |
|
|
||||||
| 发布权限 | 管理员 | 所有注册用户 |
|
|
||||||
| 适用场景 | 长篇教程、技术文章 | 随手记录、灵感分享 |
|
|
||||||
| 互动功能 | 评论 | 点赞 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 用户注册与登录
|
## 用户注册与登录
|
||||||
|
|
||||||
### 注册账号
|
### 注册账号
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ func main() {
|
|||||||
authHandler := handlers.NewAuthHandler(jwtManager)
|
authHandler := handlers.NewAuthHandler(jwtManager)
|
||||||
commentHandler := handlers.NewCommentHandler()
|
commentHandler := handlers.NewCommentHandler()
|
||||||
likeHandler := handlers.NewLikeHandler()
|
likeHandler := handlers.NewLikeHandler()
|
||||||
microHandler := handlers.NewMicroHandler()
|
|
||||||
|
|
||||||
// 创建路由
|
// 创建路由
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -71,12 +70,6 @@ func main() {
|
|||||||
api.GET("/likes", likeHandler.GetLikeStatus)
|
api.GET("/likes", likeHandler.GetLikeStatus)
|
||||||
api.POST("/likes", likeHandler.ToggleLike) // 允许访客点赞(基于 IP Hash)
|
api.POST("/likes", likeHandler.ToggleLike) // 允许访客点赞(基于 IP Hash)
|
||||||
|
|
||||||
// 微语公开接口
|
|
||||||
api.GET("/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 := api.Group("")
|
||||||
authGroup.Use(middleware.AuthMiddleware(jwtManager))
|
authGroup.Use(middleware.AuthMiddleware(jwtManager))
|
||||||
@@ -88,12 +81,6 @@ func main() {
|
|||||||
// 评论相关(需要登录才能评论)
|
// 评论相关(需要登录才能评论)
|
||||||
authGroup.POST("/comments", commentHandler.CreateComment)
|
authGroup.POST("/comments", commentHandler.CreateComment)
|
||||||
authGroup.DELETE("/comments/:id", commentHandler.DeleteComment)
|
authGroup.DELETE("/comments/:id", commentHandler.DeleteComment)
|
||||||
|
|
||||||
// 微语相关(需要登录)
|
|
||||||
authGroup.POST("/micros", microHandler.CreateMicro)
|
|
||||||
authGroup.PUT("/micros/:id", microHandler.UpdateMicro)
|
|
||||||
authGroup.DELETE("/micros/:id", microHandler.DeleteMicro)
|
|
||||||
authGroup.POST("/micros/:id/like", microHandler.ToggleLike)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理员接口
|
// 管理员接口
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ func autoMigrate() error {
|
|||||||
&models.Like{},
|
&models.Like{},
|
||||||
&models.LikeCount{},
|
&models.LikeCount{},
|
||||||
&models.PostMeta{},
|
&models.PostMeta{},
|
||||||
&models.MicroPost{},
|
|
||||||
&models.MicroPostLike{},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(µ).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
database.DB.Preload("User").First(µ, micro.ID)
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, MicroResponse{
|
|
||||||
MicroPost: micro,
|
|
||||||
LikeCount: 0,
|
|
||||||
IsLiked: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MicroHandler) GetMicros(c *gin.Context) {
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
||||||
userIDQuery := c.Query("user_id")
|
|
||||||
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if pageSize < 1 || pageSize > 50 {
|
|
||||||
pageSize = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
var micros []models.MicroPost
|
|
||||||
var total int64
|
|
||||||
|
|
||||||
query := database.DB.Model(&models.MicroPost{}).Where("is_public = ?", true)
|
|
||||||
|
|
||||||
if userIDQuery != "" {
|
|
||||||
query = query.Where("user_id = ?", userIDQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
query.Count(&total)
|
|
||||||
|
|
||||||
if err := query.
|
|
||||||
Preload("User").
|
|
||||||
Order("created_at DESC").
|
|
||||||
Offset((page - 1) * pageSize).
|
|
||||||
Limit(pageSize).
|
|
||||||
Find(µs).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUserID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
responses := make([]MicroResponse, len(micros))
|
|
||||||
for i, micro := range micros {
|
|
||||||
var likeCount int64
|
|
||||||
database.DB.Model(&models.MicroPostLike{}).Where("micro_post_id = ?", micro.ID).Count(&likeCount)
|
|
||||||
|
|
||||||
isLiked := false
|
|
||||||
if currentUserID > 0 {
|
|
||||||
var count int64
|
|
||||||
database.DB.Model(&models.MicroPostLike{}).
|
|
||||||
Where("micro_post_id = ? AND user_id = ?", micro.ID, currentUserID).
|
|
||||||
Count(&count)
|
|
||||||
isLiked = count > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
responses[i] = MicroResponse{
|
|
||||||
MicroPost: micro,
|
|
||||||
LikeCount: int(likeCount),
|
|
||||||
IsLiked: isLiked,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"data": responses,
|
|
||||||
"pagination": gin.H{
|
|
||||||
"page": page,
|
|
||||||
"page_size": pageSize,
|
|
||||||
"total": total,
|
|
||||||
"total_page": (total + int64(pageSize) - 1) / int64(pageSize),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MicroHandler) GetMicro(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var micro models.MicroPost
|
|
||||||
if err := database.DB.Preload("User").First(µ, id).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var likeCount int64
|
|
||||||
database.DB.Model(&models.MicroPostLike{}).Where("micro_post_id = ?", micro.ID).Count(&likeCount)
|
|
||||||
|
|
||||||
currentUserID, _ := middleware.GetUserID(c)
|
|
||||||
isLiked := false
|
|
||||||
if currentUserID > 0 {
|
|
||||||
var count int64
|
|
||||||
database.DB.Model(&models.MicroPostLike{}).
|
|
||||||
Where("micro_post_id = ? AND user_id = ?", micro.ID, currentUserID).
|
|
||||||
Count(&count)
|
|
||||||
isLiked = count > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, MicroResponse{
|
|
||||||
MicroPost: micro,
|
|
||||||
LikeCount: int(likeCount),
|
|
||||||
IsLiked: isLiked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MicroHandler) UpdateMicro(c *gin.Context) {
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var micro models.MicroPost
|
|
||||||
if err := database.DB.First(µ, id).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if micro.UserID != userID {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权修改"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req UpdateMicroRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
imagesJSON := "[]"
|
|
||||||
if len(req.Images) > 0 {
|
|
||||||
imagesJSON = string(mustMarshal(req.Images))
|
|
||||||
}
|
|
||||||
|
|
||||||
tagsJSON := "[]"
|
|
||||||
if len(req.Tags) > 0 {
|
|
||||||
tagsJSON = string(mustMarshal(req.Tags))
|
|
||||||
}
|
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
|
||||||
"content": req.Content,
|
|
||||||
"images": imagesJSON,
|
|
||||||
"tags": tagsJSON,
|
|
||||||
"is_public": req.IsPublic,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := database.DB.Model(µ).Updates(updates).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
database.DB.Preload("User").First(µ, micro.ID)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, micro)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MicroHandler) DeleteMicro(c *gin.Context) {
|
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
role, _ := c.Get("role")
|
|
||||||
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var micro models.MicroPost
|
|
||||||
if err := database.DB.First(µ, id).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if micro.UserID != userID && role != "admin" {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := database.DB.Delete(µ).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MicroHandler) ToggleLike(c *gin.Context) {
|
|
||||||
userID, ok := middleware.GetUserID(c)
|
|
||||||
if !ok || userID == 0 {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后再点赞"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var micro models.MicroPost
|
|
||||||
if err := database.DB.First(µ, id).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingLike models.MicroPostLike
|
|
||||||
result := database.DB.Where("micro_post_id = ? AND user_id = ?", id, userID).First(&existingLike)
|
|
||||||
|
|
||||||
if result.Error == nil {
|
|
||||||
database.DB.Delete(&existingLike)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"liked": false, "message": "取消点赞"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
like := models.MicroPostLike{
|
|
||||||
MicroPostID: uint(id),
|
|
||||||
UserID: userID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := database.DB.Create(&like).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "点赞失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"liked": true, "message": "点赞成功"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MicroHandler) GetHeatmap(c *gin.Context) {
|
|
||||||
userIDQuery := c.Query("user_id")
|
|
||||||
yearStr := c.DefaultQuery("year", strconv.Itoa(time.Now().Year()))
|
|
||||||
year, _ := strconv.Atoi(yearStr)
|
|
||||||
|
|
||||||
query := database.DB.Model(&models.MicroPost{}).
|
|
||||||
Select("DATE(created_at) as date, COUNT(*) as count").
|
|
||||||
Where("is_public = ?", true).
|
|
||||||
Group("DATE(created_at)")
|
|
||||||
|
|
||||||
if userIDQuery != "" {
|
|
||||||
query = query.Where("user_id = ?", userIDQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
if year > 0 {
|
|
||||||
startDate := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
endDate := time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
query = query.Where("created_at >= ? AND created_at < ?", startDate, endDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
var results []struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := query.Scan(&results).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, results)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MicroHandler) GetStats(c *gin.Context) {
|
|
||||||
userIDQuery := c.Query("user_id")
|
|
||||||
|
|
||||||
var totalMicros int64
|
|
||||||
var totalUsers int64
|
|
||||||
|
|
||||||
query := database.DB.Model(&models.MicroPost{}).Where("is_public = ?", true)
|
|
||||||
if userIDQuery != "" {
|
|
||||||
query = query.Where("user_id = ?", userIDQuery)
|
|
||||||
}
|
|
||||||
query.Count(&totalMicros)
|
|
||||||
|
|
||||||
database.DB.Model(&models.User{}).Count(&totalUsers)
|
|
||||||
|
|
||||||
var topUsers []struct {
|
|
||||||
UserID uint `json:"user_id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Avatar string `json:"avatar"`
|
|
||||||
PostCount int `json:"post_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
database.DB.Model(&models.MicroPost{}).
|
|
||||||
Select("user_id, COUNT(*) as post_count").
|
|
||||||
Where("is_public = ?", true).
|
|
||||||
Group("user_id").
|
|
||||||
Order("post_count DESC").
|
|
||||||
Limit(10).
|
|
||||||
Scan(&topUsers)
|
|
||||||
|
|
||||||
for i := range topUsers {
|
|
||||||
var user models.User
|
|
||||||
if err := database.DB.First(&user, topUsers[i].UserID).Error; err == nil {
|
|
||||||
topUsers[i].Username = user.Username
|
|
||||||
topUsers[i].Nickname = user.Nickname
|
|
||||||
topUsers[i].Avatar = user.Avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"total_micros": totalMicros,
|
|
||||||
"total_users": totalUsers,
|
|
||||||
"top_users": topUsers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -62,25 +62,3 @@ type PostMeta struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,63 @@
|
|||||||
class="w-full p-4 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
class="w-full p-4 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
||||||
rows="4"
|
rows="4"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex justify-end mt-2">
|
<!-- 工具栏 -->
|
||||||
|
<div class="flex justify-between items-center mt-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Emoji 按钮 -->
|
||||||
|
<div class="relative emoji-picker-container">
|
||||||
|
<button
|
||||||
|
@click.stop="showEmojiPicker = !showEmojiPicker"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 text-sm text-foreground/60 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors"
|
||||||
|
title="添加表情"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
<span>表情</span>
|
||||||
|
</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"
|
||||||
|
:disabled="uploadingImage"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 text-sm text-foreground/60 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors disabled:opacity-50"
|
||||||
|
title="上传图片"
|
||||||
|
>
|
||||||
|
<svg v-if="!uploadingImage" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
<svg v-else class="w-5 h-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ uploadingImage ? '上传中...' : '图片' }}</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="imageInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleImageUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="submitComment"
|
@click="submitComment"
|
||||||
:disabled="!newComment.trim() || submitting"
|
:disabled="!newComment.trim() || submitting"
|
||||||
@@ -19,6 +75,8 @@
|
|||||||
{{ submitting ? '发布中...' : '发布评论' }}
|
{{ submitting ? '发布中...' : '发布评论' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 上传错误提示 -->
|
||||||
|
<p v-if="uploadError" class="text-sm text-red-500 mt-1">{{ uploadError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 未登录提示 -->
|
<!-- 未登录提示 -->
|
||||||
@@ -56,7 +114,7 @@
|
|||||||
<span class="font-medium">{{ getDisplayName(comment.user) }}</span>
|
<span class="font-medium">{{ getDisplayName(comment.user) }}</span>
|
||||||
<span class="text-xs text-foreground/40">{{ formatDate(comment.created_at) }}</span>
|
<span class="text-xs text-foreground/40">{{ formatDate(comment.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content prose prose-sm dark:prose-invert max-w-none" v-html="renderMarkdown(comment.content)"></div>
|
<div class="comment-content prose prose-sm dark:prose-invert max-w-none" v-html="renderMarkdown(comment.content)" @click="handleContentClick"></div>
|
||||||
|
|
||||||
<!-- 回复按钮 -->
|
<!-- 回复按钮 -->
|
||||||
<button
|
<button
|
||||||
@@ -76,8 +134,64 @@
|
|||||||
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"
|
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"
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex justify-end gap-2 mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
<button @click="replyTo = null" class="btn-secondary text-sm">取消</button>
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Emoji 按钮 -->
|
||||||
|
<div class="relative emoji-picker-container">
|
||||||
|
<button
|
||||||
|
@click.stop="showReplyEmojiPicker = showReplyEmojiPicker === comment.id ? null : comment.id"
|
||||||
|
class="flex items-center gap-1 px-2 py-1 text-xs text-foreground/60 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors"
|
||||||
|
title="添加表情"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
<span>表情</span>
|
||||||
|
</button>
|
||||||
|
<!-- Emoji 选择面板 -->
|
||||||
|
<div
|
||||||
|
v-if="showReplyEmojiPicker === comment.id"
|
||||||
|
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 flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="emoji in commonEmojis"
|
||||||
|
:key="emoji"
|
||||||
|
@click="insertReplyEmoji(emoji, comment.id)"
|
||||||
|
class="w-7 h-7 flex items-center justify-center text-lg hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 图片上传按钮 -->
|
||||||
|
<button
|
||||||
|
@click="triggerReplyImageUpload(comment.id)"
|
||||||
|
:disabled="uploadingReplyImage"
|
||||||
|
class="flex items-center gap-1 px-2 py-1 text-xs text-foreground/60 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors disabled:opacity-50"
|
||||||
|
title="上传图片"
|
||||||
|
>
|
||||||
|
<svg v-if="!uploadingReplyImage" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
<svg v-else class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ uploadingReplyImage ? '上传中...' : '图片' }}</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
:ref="el => replyImageInputs[comment.id] = el as HTMLInputElement"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="(e) => handleReplyImageUpload(e, comment.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="replyTo = null; showReplyEmojiPicker = null" class="btn-secondary text-sm">取消</button>
|
||||||
<button
|
<button
|
||||||
@click="submitReply(comment.id)"
|
@click="submitReply(comment.id)"
|
||||||
:disabled="!replyContent.trim() || submitting"
|
:disabled="!replyContent.trim() || submitting"
|
||||||
@@ -87,6 +201,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 子评论 -->
|
<!-- 子评论 -->
|
||||||
<div v-if="comment.replies && comment.replies.length > 0" class="mt-4 ml-14 space-y-4">
|
<div v-if="comment.replies && comment.replies.length > 0" class="mt-4 ml-14 space-y-4">
|
||||||
@@ -103,7 +218,7 @@
|
|||||||
<span class="font-medium text-sm">{{ getDisplayName(reply.user) }}</span>
|
<span class="font-medium text-sm">{{ getDisplayName(reply.user) }}</span>
|
||||||
<span class="text-xs text-foreground/40">{{ formatDate(reply.created_at) }}</span>
|
<span class="text-xs text-foreground/40">{{ formatDate(reply.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content prose prose-sm dark:prose-invert max-w-none text-sm" v-html="renderMarkdown(reply.content)"></div>
|
<div class="comment-content prose prose-sm dark:prose-invert max-w-none text-sm" v-html="renderMarkdown(reply.content)" @click="handleContentClick"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,11 +245,89 @@
|
|||||||
下一页
|
下一页
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片预览模态框(灯箱) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="lightbox">
|
||||||
|
<div
|
||||||
|
v-if="previewImage"
|
||||||
|
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 backdrop-blur-sm select-none"
|
||||||
|
@click="closePreview"
|
||||||
|
@wheel.prevent="handleWheel"
|
||||||
|
>
|
||||||
|
<!-- 关闭按钮 -->
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-4 z-10 p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
|
||||||
|
@click="closePreview"
|
||||||
|
aria-label="关闭预览"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 缩放控制按钮 -->
|
||||||
|
<div class="absolute top-4 left-4 z-10 flex gap-2">
|
||||||
|
<button
|
||||||
|
class="p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
|
||||||
|
@click.stop="zoomOut"
|
||||||
|
:disabled="imageScale <= 0.5"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': imageScale <= 0.5 }"
|
||||||
|
aria-label="缩小"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all text-sm min-w-[60px]"
|
||||||
|
@click.stop="resetZoom"
|
||||||
|
aria-label="重置缩放"
|
||||||
|
>
|
||||||
|
{{ Math.round(imageScale * 100) }}%
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
|
||||||
|
@click.stop="zoomIn"
|
||||||
|
:disabled="imageScale >= 3"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': imageScale >= 3 }"
|
||||||
|
aria-label="放大"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片容器 -->
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden"
|
||||||
|
@click.stop
|
||||||
|
@mousedown="startDrag"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref="previewImgRef"
|
||||||
|
:src="previewImage"
|
||||||
|
class="rounded-lg shadow-2xl transition-transform duration-100"
|
||||||
|
:style="imageStyle"
|
||||||
|
@click.stop
|
||||||
|
@dragstart.prevent
|
||||||
|
alt="图片预览"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提示文字 -->
|
||||||
|
<p class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
|
||||||
|
滚轮缩放 · 拖拽移动 · ESC 或点击背景关闭
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
// 配置 marked 选项 - 安全模式,禁止 HTML 标签
|
// 配置 marked 选项 - 安全模式,禁止 HTML 标签
|
||||||
@@ -146,9 +339,13 @@ marked.setOptions({
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
postId: string;
|
postId: string;
|
||||||
apiBaseUrl?: string;
|
apiBaseUrl?: string;
|
||||||
|
imageUploadUrl?: string;
|
||||||
|
imageUploadToken?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const apiBaseUrl = props.apiBaseUrl || 'http://localhost:8080/api';
|
const apiBaseUrl = props.apiBaseUrl || 'http://localhost:8080/api';
|
||||||
|
const imageUploadUrl = props.imageUploadUrl || 'https://picturebed.jiao77.cn/api/index.php';
|
||||||
|
const imageUploadToken = props.imageUploadToken || 'blog';
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const comments = ref<any[]>([]);
|
const comments = ref<any[]>([]);
|
||||||
@@ -164,12 +361,53 @@ const pagination = ref({
|
|||||||
totalPage: 0,
|
totalPage: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 图片上传相关状态
|
||||||
|
const imageInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const uploadingImage = ref(false);
|
||||||
|
const uploadError = ref('');
|
||||||
|
const previewImage = ref<string | null>(null);
|
||||||
|
|
||||||
|
// 回复图片上传相关状态
|
||||||
|
const replyImageInputs = ref<Record<number, HTMLInputElement>>({});
|
||||||
|
const uploadingReplyImage = ref(false);
|
||||||
|
|
||||||
|
// Emoji 选择器状态
|
||||||
|
const showEmojiPicker = ref(false);
|
||||||
|
const showReplyEmojiPicker = ref<number | null>(null);
|
||||||
|
|
||||||
|
// 常用 emoji 列表
|
||||||
|
const commonEmojis = [
|
||||||
|
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂',
|
||||||
|
'😉', '😌', '😍', '🥰', '😘', '😋', '😛', '😜', '🤪', '😝',
|
||||||
|
'🤗', '🤔', '🤭', '🤫', '🤥', '😶', '😐', '😑', '😏', '😒',
|
||||||
|
'🙄', '😬', '😮', '🥱', '😴', '🤤', '😷', '🤒', '🤕', '🤢',
|
||||||
|
'👍', '👎', '👏', '🙌', '🤝', '🙏', '💪', '🎉', '🎊', '💯',
|
||||||
|
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '❣️', '💕',
|
||||||
|
'🔥', '⭐', '🌟', '✨', '💫', '🎯', '🏆', '🚀', '💡', '📌',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 灯箱缩放和拖拽状态
|
||||||
|
const previewImgRef = ref<HTMLImageElement | null>(null);
|
||||||
|
const imageScale = ref(1);
|
||||||
|
const imageTranslate = ref({ x: 0, y: 0 });
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragStart = ref({ x: 0, y: 0 });
|
||||||
|
const dragOffset = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
// 计算属性 - 仅在浏览器环境中访问 localStorage
|
// 计算属性 - 仅在浏览器环境中访问 localStorage
|
||||||
const isLoggedIn = computed(() => {
|
const isLoggedIn = computed(() => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
return !!localStorage.getItem('token');
|
return !!localStorage.getItem('token');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 图片样式计算属性
|
||||||
|
const imageStyle = computed(() => ({
|
||||||
|
transform: `translate(${imageTranslate.value.x}px, ${imageTranslate.value.y}px) scale(${imageScale.value})`,
|
||||||
|
cursor: isDragging.value ? 'grabbing' : 'grab',
|
||||||
|
maxWidth: '90vw',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
}));
|
||||||
|
|
||||||
// 获取认证头
|
// 获取认证头
|
||||||
function getAuthHeaders(): Record<string, string> {
|
function getAuthHeaders(): Record<string, string> {
|
||||||
if (typeof window === 'undefined') return {};
|
if (typeof window === 'undefined') return {};
|
||||||
@@ -271,6 +509,161 @@ function loadPage(page: number) {
|
|||||||
loadComments();
|
loadComments();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发图片上传
|
||||||
|
function triggerImageUpload() {
|
||||||
|
if (imageInput.value) {
|
||||||
|
imageInput.value.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理图片上传
|
||||||
|
async function handleImageUpload(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
uploadError.value = '请选择图片文件';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小 (最大 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
uploadError.value = '图片大小不能超过 5MB';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadError.value = '';
|
||||||
|
uploadingImage.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
formData.append('token', imageUploadToken);
|
||||||
|
|
||||||
|
const response = await fetch(imageUploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.result === 'success' && data.url) {
|
||||||
|
// 插入 Markdown 图片语法到评论内容
|
||||||
|
const imageName = data.srcName || file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
const markdown = ``;
|
||||||
|
|
||||||
|
// 在光标位置插入或追加到末尾
|
||||||
|
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const text = newComment.value;
|
||||||
|
newComment.value = text.substring(0, start) + markdown + text.substring(end);
|
||||||
|
// 恢复焦点
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + markdown.length, start + markdown.length);
|
||||||
|
} else {
|
||||||
|
newComment.value += markdown;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uploadError.value = data.message || '上传失败,请稍后重试';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload image:', error);
|
||||||
|
uploadError.value = '上传失败,请检查网络连接';
|
||||||
|
} finally {
|
||||||
|
uploadingImage.value = false;
|
||||||
|
// 清空 input 以便重复选择同一文件
|
||||||
|
if (target) {
|
||||||
|
target.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入 emoji 到评论
|
||||||
|
function insertEmoji(emoji: string) {
|
||||||
|
const textarea = document.querySelector('.comment-section textarea') as HTMLTextAreaElement;
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const text = newComment.value;
|
||||||
|
newComment.value = text.substring(0, start) + emoji + text.substring(end);
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
// 恢复焦点
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||||
|
} else {
|
||||||
|
newComment.value += emoji;
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入 emoji 到回复
|
||||||
|
function insertReplyEmoji(emoji: string, commentId: number) {
|
||||||
|
replyContent.value += emoji;
|
||||||
|
showReplyEmojiPicker.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发回复图片上传
|
||||||
|
function triggerReplyImageUpload(commentId: number) {
|
||||||
|
const input = replyImageInputs.value[commentId];
|
||||||
|
if (input) {
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理回复图片上传
|
||||||
|
async function handleReplyImageUpload(event: Event, commentId: number) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('请选择图片文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小 (最大 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('图片大小不能超过 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadingReplyImage.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
formData.append('token', imageUploadToken);
|
||||||
|
|
||||||
|
const response = await fetch(imageUploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.result === 'success' && data.url) {
|
||||||
|
const imageName = data.srcName || file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
const markdown = ``;
|
||||||
|
replyContent.value += markdown;
|
||||||
|
} else {
|
||||||
|
alert(data.message || '上传失败,请稍后重试');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload image:', error);
|
||||||
|
alert('上传失败,请检查网络连接');
|
||||||
|
} finally {
|
||||||
|
uploadingReplyImage.value = false;
|
||||||
|
if (target) {
|
||||||
|
target.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取用户首字母
|
// 获取用户首字母
|
||||||
function getInitial(user: any): string {
|
function getInitial(user: any): string {
|
||||||
if (!user) return '?';
|
if (!user) return '?';
|
||||||
@@ -318,8 +711,142 @@ function renderMarkdown(content: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理评论区域点击事件(事件委托)
|
||||||
|
function handleContentClick(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.tagName === 'IMG') {
|
||||||
|
const img = target as HTMLImageElement;
|
||||||
|
previewImage.value = img.src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭图片预览
|
||||||
|
function closePreview() {
|
||||||
|
previewImage.value = null;
|
||||||
|
// 重置缩放和位置
|
||||||
|
imageScale.value = 1;
|
||||||
|
imageTranslate.value = { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 放大
|
||||||
|
function zoomIn() {
|
||||||
|
if (imageScale.value < 3) {
|
||||||
|
imageScale.value = Math.min(3, imageScale.value + 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩小
|
||||||
|
function zoomOut() {
|
||||||
|
if (imageScale.value > 0.5) {
|
||||||
|
imageScale.value = Math.max(0.5, imageScale.value - 0.25);
|
||||||
|
// 缩小时限制图片不超出边界
|
||||||
|
limitTranslate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置缩放
|
||||||
|
function resetZoom() {
|
||||||
|
imageScale.value = 1;
|
||||||
|
imageTranslate.value = { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚轮缩放
|
||||||
|
function handleWheel(event: WheelEvent) {
|
||||||
|
if (!previewImage.value) return;
|
||||||
|
|
||||||
|
const delta = event.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
const newScale = Math.max(0.5, Math.min(3, imageScale.value + delta));
|
||||||
|
|
||||||
|
if (newScale !== imageScale.value) {
|
||||||
|
imageScale.value = newScale;
|
||||||
|
if (newScale < 1) {
|
||||||
|
limitTranslate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始拖拽
|
||||||
|
function startDrag(event: MouseEvent) {
|
||||||
|
if (imageScale.value <= 1) return;
|
||||||
|
|
||||||
|
isDragging.value = true;
|
||||||
|
dragStart.value = { x: event.clientX, y: event.clientY };
|
||||||
|
dragOffset.value = { ...imageTranslate.value };
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleDrag);
|
||||||
|
document.addEventListener('mouseup', stopDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽中
|
||||||
|
function handleDrag(event: MouseEvent) {
|
||||||
|
if (!isDragging.value) return;
|
||||||
|
|
||||||
|
const dx = event.clientX - dragStart.value.x;
|
||||||
|
const dy = event.clientY - dragStart.value.y;
|
||||||
|
|
||||||
|
imageTranslate.value = {
|
||||||
|
x: dragOffset.value.x + dx,
|
||||||
|
y: dragOffset.value.y + dy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止拖拽
|
||||||
|
function stopDrag() {
|
||||||
|
isDragging.value = false;
|
||||||
|
document.removeEventListener('mousemove', handleDrag);
|
||||||
|
document.removeEventListener('mouseup', stopDrag);
|
||||||
|
limitTranslate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制拖拽范围
|
||||||
|
function limitTranslate() {
|
||||||
|
if (!previewImgRef.value) return;
|
||||||
|
|
||||||
|
const img = previewImgRef.value;
|
||||||
|
const maxOffsetX = (img.naturalWidth * imageScale.value - window.innerWidth * 0.9) / 2;
|
||||||
|
const maxOffsetY = (img.naturalHeight * imageScale.value - window.innerHeight * 0.9) / 2;
|
||||||
|
|
||||||
|
imageTranslate.value = {
|
||||||
|
x: Math.max(-maxOffsetX, Math.min(maxOffsetX, imageTranslate.value.x)),
|
||||||
|
y: Math.max(-maxOffsetY, Math.min(maxOffsetY, imageTranslate.value.y)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果图片比视口小,不允许移动
|
||||||
|
if (img.naturalWidth * imageScale.value <= window.innerWidth * 0.9) {
|
||||||
|
imageTranslate.value.x = 0;
|
||||||
|
}
|
||||||
|
if (img.naturalHeight * imageScale.value <= window.innerHeight * 0.9) {
|
||||||
|
imageTranslate.value.y = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理键盘事件
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && previewImage.value) {
|
||||||
|
previewImage.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadComments();
|
loadComments();
|
||||||
|
// 监听键盘事件
|
||||||
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
// 点击外部关闭 emoji 选择器
|
||||||
|
document.addEventListener('click', closeEmojiPickers);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 关闭所有 emoji 选择器
|
||||||
|
function closeEmojiPickers(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.emoji-picker-container')) {
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
showReplyEmojiPicker.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
|
document.removeEventListener('click', closeEmojiPickers);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -410,7 +937,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comment-content :deep(img) {
|
.comment-content :deep(img) {
|
||||||
@apply max-w-full rounded-lg my-2;
|
@apply max-w-full rounded-lg my-2 cursor-pointer hover:opacity-90 transition-opacity;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-content :deep(table) {
|
.comment-content :deep(table) {
|
||||||
@@ -425,4 +954,25 @@ onMounted(() => {
|
|||||||
.comment-content :deep(th) {
|
.comment-content :deep(th) {
|
||||||
@apply bg-gray-100 dark:bg-gray-800 font-bold;
|
@apply bg-gray-100 dark:bg-gray-800 font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 灯箱过渡动画 */
|
||||||
|
.lightbox-enter-active,
|
||||||
|
.lightbox-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-enter-active img,
|
||||||
|
.lightbox-leave-active img {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-enter-from,
|
||||||
|
.lightbox-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-enter-from img,
|
||||||
|
.lightbox-leave-to img {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
1
src/content/blog
Submodule
1
src/content/blog
Submodule
Submodule src/content/blog added at 2d4b3dfdab
@@ -1,88 +0,0 @@
|
|||||||
---
|
|
||||||
title: 'NovaBlog 入门指南'
|
|
||||||
description: '这是一篇介绍 NovaBlog 博客系统核心功能的示例文章,展示 MDX 动态组件支持。'
|
|
||||||
pubDate: 2026-02-28
|
|
||||||
author: 'NovaBlog Team'
|
|
||||||
category: '教程'
|
|
||||||
tags: ['入门', 'MDX', 'Astro']
|
|
||||||
heroImage: '/images/hello-world.jpg'
|
|
||||||
---
|
|
||||||
|
|
||||||
# 欢迎来到 NovaBlog
|
|
||||||
|
|
||||||
NovaBlog 是一个极简、高效的程序员博客系统,采用 **静态渲染 + 轻量级微服务** 架构。
|
|
||||||
|
|
||||||
## 核心特性
|
|
||||||
|
|
||||||
### 🚀 极致性能
|
|
||||||
|
|
||||||
基于 Astro 的 Islands Architecture(群岛架构),大部分页面为 Zero-JS,仅在需要交互的地方加载 JavaScript。
|
|
||||||
|
|
||||||
### ✍️ MDX 支持
|
|
||||||
|
|
||||||
在 Markdown 中直接嵌入交互组件:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<Counter client:visible />
|
|
||||||
```
|
|
||||||
|
|
||||||
上面的组件会在可见时自动加载并挂载 JavaScript。
|
|
||||||
|
|
||||||
### 📐 数学公式排版
|
|
||||||
|
|
||||||
支持复杂的数学公式渲染:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ integral_0^infinity e^(-x^2) dif x = sqrt(pi) / 2 $
|
|
||||||
```
|
|
||||||
|
|
||||||
## 代码高亮
|
|
||||||
|
|
||||||
支持多种编程语言的语法高亮:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// TypeScript 示例
|
|
||||||
interface Post {
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPosts(): Promise<Post[]> {
|
|
||||||
return await fetch('/api/posts').then(res => res.json());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Go 示例
|
|
||||||
package main
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
fmt.Println("Hello, NovaBlog!")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 表格支持
|
|
||||||
|
|
||||||
| 特性 | 描述 |
|
|
||||||
|------|------|
|
|
||||||
| 静态渲染 | 构建时生成 HTML,极速加载 |
|
|
||||||
| MDX 支持 | 在 Markdown 中嵌入组件 |
|
|
||||||
| 低资源 | 2C1G 即可运行 |
|
|
||||||
| Docker | 一键容器化部署 |
|
|
||||||
|
|
||||||
## 引用
|
|
||||||
|
|
||||||
> 优秀的博客系统应该让作者专注于内容,而非配置。
|
|
||||||
>
|
|
||||||
> — NovaBlog 设计理念
|
|
||||||
|
|
||||||
## 下一步
|
|
||||||
|
|
||||||
1. 在 `src/content/blog/` 目录下创建新的 `.md` 或 `.mdx` 文件
|
|
||||||
2. 配置 Frontmatter 元数据
|
|
||||||
3. 运行 `npm run dev` 预览效果
|
|
||||||
|
|
||||||
开始你的写作之旅吧! 🎉
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
---
|
|
||||||
title: 'LaTeX 公式渲染测试'
|
|
||||||
description: '测试 LaTeX 数学公式渲染功能'
|
|
||||||
pubDate: 2026-03-01
|
|
||||||
author: 'NovaBlog Team'
|
|
||||||
category: '测试'
|
|
||||||
tags: ['测试', 'LaTeX', '数学']
|
|
||||||
---
|
|
||||||
|
|
||||||
import MathFlipCard from '../../components/react/MathFlipCard';
|
|
||||||
|
|
||||||
# LaTeX 公式渲染测试
|
|
||||||
|
|
||||||
## 行内公式
|
|
||||||
|
|
||||||
这是一个行内公式:$E = mc^2$,爱因斯坦的质能方程。
|
|
||||||
|
|
||||||
勾股定理:$a^2 + b^2 = c^2$
|
|
||||||
|
|
||||||
## 块级公式
|
|
||||||
|
|
||||||
高斯积分:
|
|
||||||
|
|
||||||
$$
|
|
||||||
\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
|
|
||||||
$$
|
|
||||||
|
|
||||||
## 翻转卡片公式展示
|
|
||||||
|
|
||||||
下面使用翻转卡片展示公式,点击"显示 LaTeX"按钮可以查看源码:
|
|
||||||
|
|
||||||
### 质能方程
|
|
||||||
|
|
||||||
<MathFlipCard latex="E = mc^2" client:load />
|
|
||||||
|
|
||||||
### 麦克斯韦方程组
|
|
||||||
|
|
||||||
<MathFlipCard latex="\nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0}" client:load />
|
|
||||||
|
|
||||||
### 薛定谔方程
|
|
||||||
|
|
||||||
<MathFlipCard latex="i\hbar\frac{\partial}{\partial t}\Psi(\mathbf{r},t) = \left[-\frac{\hbar^2}{2m}\nabla^2 + V(\mathbf{r},t)\right]\Psi(\mathbf{r},t)" client:load />
|
|
||||||
|
|
||||||
### 傅里叶变换
|
|
||||||
|
|
||||||
<MathFlipCard latex="\hat{f}(\xi) = \int_{-\infty}^{\infty} f(x) e^{-2\pi i x \xi} dx" client:load />
|
|
||||||
|
|
||||||
### 矩阵运算
|
|
||||||
|
|
||||||
<MathFlipCard latex="\begin{pmatrix} a & b \\ c & d \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} = \begin{pmatrix} ax + by \\ cx + dy \end{pmatrix}" client:load />
|
|
||||||
|
|
||||||
## 传统公式展示(对比)
|
|
||||||
|
|
||||||
麦克斯韦方程组:
|
|
||||||
|
|
||||||
$$
|
|
||||||
\nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0}
|
|
||||||
$$
|
|
||||||
|
|
||||||
$$
|
|
||||||
\nabla \cdot \mathbf{B} = 0
|
|
||||||
$$
|
|
||||||
|
|
||||||
$$
|
|
||||||
\nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}
|
|
||||||
$$
|
|
||||||
|
|
||||||
$$
|
|
||||||
\nabla \times \mathbf{B} = \mu_0 \mathbf{J} + \mu_0 \varepsilon_0 \frac{\partial \mathbf{E}}{\partial t}
|
|
||||||
$$
|
|
||||||
|
|
||||||
## 复杂公式
|
|
||||||
|
|
||||||
薛定谔方程:
|
|
||||||
|
|
||||||
$$
|
|
||||||
i\hbar\frac{\partial}{\partial t}\Psi(\mathbf{r},t) = \left[-\frac{\hbar^2}{2m}\nabla^2 + V(\mathbf{r},t)\right]\Psi(\mathbf{r},t)
|
|
||||||
$$
|
|
||||||
|
|
||||||
傅里叶变换:
|
|
||||||
|
|
||||||
$$
|
|
||||||
\hat{f}(\xi) = \int_{-\infty}^{\infty} f(x) e^{-2\pi i x \xi} dx
|
|
||||||
$$
|
|
||||||
|
|
||||||
矩阵:
|
|
||||||
|
|
||||||
$$
|
|
||||||
\begin{pmatrix}
|
|
||||||
a & b \\
|
|
||||||
c & d
|
|
||||||
\end{pmatrix}
|
|
||||||
\begin{pmatrix}
|
|
||||||
x \\
|
|
||||||
y
|
|
||||||
\end{pmatrix}
|
|
||||||
=
|
|
||||||
\begin{pmatrix}
|
|
||||||
ax + by \\
|
|
||||||
cx + dy
|
|
||||||
\end{pmatrix}
|
|
||||||
$$
|
|
||||||
|
|
||||||
求和与积分:
|
|
||||||
|
|
||||||
$$
|
|
||||||
\sum_{i=1}^{n} i = \frac{n(n+1)}{2}
|
|
||||||
$$
|
|
||||||
|
|
||||||
$$
|
|
||||||
\prod_{i=1}^{n} i = n!
|
|
||||||
$$
|
|
||||||
|
|
||||||
## 测试完成
|
|
||||||
|
|
||||||
如果你能看到以上公式正确渲染,说明 LaTeX 公式支持已经成功配置!
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
---
|
|
||||||
title: React 动效组件展示
|
|
||||||
description: 展示 NovaBlog 中可用的 React 动效 HTML 组件,包括悬浮卡片、打字机效果、翻转卡片、粒子背景和数学公式卡片
|
|
||||||
pubDate: 2024-01-20
|
|
||||||
author: NovaBlog
|
|
||||||
tags: [React, 动效, 组件, 教程]
|
|
||||||
category: 教程
|
|
||||||
heroImage: '/images/hello-world.jpg'
|
|
||||||
---
|
|
||||||
|
|
||||||
import AnimatedCard from '../../components/react/AnimatedCard';
|
|
||||||
import TypewriterText from '../../components/react/TypewriterText';
|
|
||||||
import FlipCard from '../../components/react/FlipCard';
|
|
||||||
import ParticleBackground from '../../components/react/ParticleBackground';
|
|
||||||
import MathFlipCard from '../../components/react/MathFlipCard';
|
|
||||||
import MergeTable, { Cell } from '../../components/react/MergeTable';
|
|
||||||
|
|
||||||
# React 动效组件展示
|
|
||||||
|
|
||||||
NovaBlog 支持在 MDX 中直接使用 React 组件,实现丰富的交互动效。本文展示了一些内置的动效组件示例。
|
|
||||||
|
|
||||||
## 🎴 悬浮卡片 (AnimatedCard)
|
|
||||||
|
|
||||||
鼠标悬停时卡片会浮起并放大,配合阴影效果增强立体感。
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1.5rem', margin: '2rem 0' }}>
|
|
||||||
<AnimatedCard
|
|
||||||
title="🚀 快速开发"
|
|
||||||
description="使用 Astro + React 实现极速开发体验"
|
|
||||||
color="#3b82f6"
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
<AnimatedCard
|
|
||||||
title="🎨 精美设计"
|
|
||||||
description="内置多种动效组件,轻松创建炫酷页面"
|
|
||||||
color="#10b981"
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
<AnimatedCard
|
|
||||||
title="⚡ 高性能"
|
|
||||||
description="Islands 架构,按需加载,极致性能"
|
|
||||||
color="#f59e0b"
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## ⌨️ 打字机效果 (TypewriterText)
|
|
||||||
|
|
||||||
模拟打字机的逐字显示效果,支持循环播放。
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
background: '#1a1a2e',
|
|
||||||
padding: '2rem',
|
|
||||||
borderRadius: '1rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
margin: '2rem 0'
|
|
||||||
}}>
|
|
||||||
<TypewriterText
|
|
||||||
text="Hello, NovaBlog! 欢迎来到你的新博客..."
|
|
||||||
speed={80}
|
|
||||||
loop={true}
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### 使用方式
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<TypewriterText
|
|
||||||
text="你要显示的文字"
|
|
||||||
speed={100} // 打字速度(毫秒)
|
|
||||||
loop={true} // 是否循环播放
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 翻转卡片 (FlipCard)
|
|
||||||
|
|
||||||
点击卡片实现 3D 翻转效果,适合展示正反两面内容。
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '1.5rem', margin: '2rem 0' }}>
|
|
||||||
<FlipCard
|
|
||||||
frontTitle="💡 小提示"
|
|
||||||
frontDescription="点击翻转查看更多"
|
|
||||||
backTitle="✅ 详细说明"
|
|
||||||
backDescription="这是一个翻转卡片的背面内容"
|
|
||||||
frontColor="#8b5cf6"
|
|
||||||
backColor="#06b6d4"
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
<FlipCard
|
|
||||||
frontTitle="🎯 技术栈"
|
|
||||||
frontDescription="React + TypeScript"
|
|
||||||
backTitle="📦 组件库"
|
|
||||||
backDescription="支持自定义样式和动画"
|
|
||||||
frontColor="#ec4899"
|
|
||||||
backColor="#14b8a6"
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### 使用方式
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<FlipCard
|
|
||||||
frontContent={<div>正面内容</div>}
|
|
||||||
backContent={<div>背面内容</div>}
|
|
||||||
frontColor="#3b82f6"
|
|
||||||
backColor="#10b981"
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✨ 粒子背景 (ParticleBackground)
|
|
||||||
|
|
||||||
基于 Canvas 的粒子动画背景,粒子之间会自动连线,营造科技感。
|
|
||||||
|
|
||||||
<ParticleBackground
|
|
||||||
particleCount={60}
|
|
||||||
color="#60a5fa"
|
|
||||||
speed={0.8}
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
|
|
||||||
### 自定义内容
|
|
||||||
|
|
||||||
<ParticleBackground
|
|
||||||
particleCount={30}
|
|
||||||
color="#f472b6"
|
|
||||||
speed={1.5}
|
|
||||||
client:load
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<h2 style={{ fontSize: '2.5rem', marginBottom: '1rem' }}>🎉 自定义内容</h2>
|
|
||||||
<p>可以在粒子背景上放置任意内容</p>
|
|
||||||
</div>
|
|
||||||
</ParticleBackground>
|
|
||||||
|
|
||||||
## 📐 数学公式翻转卡片 (MathFlipCard)
|
|
||||||
|
|
||||||
专门用于展示 LaTeX 数学公式的翻转卡片组件。正面显示渲染后的公式,点击"显示 LaTeX"按钮可以查看源码。
|
|
||||||
|
|
||||||
### 质能方程
|
|
||||||
|
|
||||||
<MathFlipCard latex="E = mc^2" client:load />
|
|
||||||
|
|
||||||
### 麦克斯韦方程组
|
|
||||||
|
|
||||||
<MathFlipCard latex="\nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0}" client:load />
|
|
||||||
|
|
||||||
### 薛定谔方程
|
|
||||||
|
|
||||||
<MathFlipCard latex="i\hbar\frac{\partial}{\partial t}\Psi(\mathbf{r},t) = \left[-\frac{\hbar^2}{2m}\nabla^2 + V(\mathbf{r},t)\right]\Psi(\mathbf{r},t)" client:load />
|
|
||||||
|
|
||||||
### 傅里叶变换
|
|
||||||
|
|
||||||
<MathFlipCard latex="\hat{f}(\xi) = \int_{-\infty}^{\infty} f(x) e^{-2\pi i x \xi} dx" client:load />
|
|
||||||
|
|
||||||
### 矩阵运算
|
|
||||||
|
|
||||||
<MathFlipCard latex="\begin{pmatrix} a & b \\ c & d \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} = \begin{pmatrix} ax + by \\ cx + dy \end{pmatrix}" client:load />
|
|
||||||
|
|
||||||
### 使用方式
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import MathFlipCard from '../../components/react/MathFlipCard';
|
|
||||||
|
|
||||||
<MathFlipCard
|
|
||||||
latex="E = mc^2"
|
|
||||||
displayMode={true} // 可选,默认为 true
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**属性说明**:
|
|
||||||
- `latex`:LaTeX 公式字符串
|
|
||||||
- `displayMode`:是否为块级公式(可选,默认为 true)
|
|
||||||
- `className`:自定义 CSS 类名(可选)
|
|
||||||
|
|
||||||
**特点**:
|
|
||||||
- 点击"显示 LaTeX"按钮可查看公式源码
|
|
||||||
- 点击"显示公式"按钮返回渲染结果
|
|
||||||
- 支持 KaTeX 的所有语法
|
|
||||||
- 适合教学和技术文档
|
|
||||||
|
|
||||||
## 📊 合并单元格表格 (MergeTable)
|
|
||||||
|
|
||||||
支持跨行跨列合并的高级表格组件,类似 Typst 的 tablex 功能。
|
|
||||||
|
|
||||||
### 基础示例:课程表
|
|
||||||
|
|
||||||
<MergeTable
|
|
||||||
headerRows={1}
|
|
||||||
client:load
|
|
||||||
data={[
|
|
||||||
[
|
|
||||||
Cell({ children: '时间/星期', header: true, rowspan: 2, align: 'center' }),
|
|
||||||
Cell({ children: '星期一', header: true, colspan: 2, align: 'center' }),
|
|
||||||
Cell({ children: '星期二', header: true, colspan: 2, align: 'center' }),
|
|
||||||
Cell({ children: '星期三', header: true, colspan: 2, align: 'center' }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
Cell({ children: '上午', header: true }),
|
|
||||||
Cell({ children: '下午', header: true }),
|
|
||||||
Cell({ children: '上午', header: true }),
|
|
||||||
Cell({ children: '下午', header: true }),
|
|
||||||
Cell({ children: '上午', header: true }),
|
|
||||||
Cell({ children: '下午', header: true }),
|
|
||||||
],
|
|
||||||
['第1节', '数学', '语文', '英语', '物理', '化学'],
|
|
||||||
['第2节', '语文', '数学', '物理', '英语', '生物'],
|
|
||||||
[
|
|
||||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
|
||||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
|
||||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
|
||||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
|
||||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
|
||||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
['第3节', '体育', '音乐', '美术', '历史', '地理'],
|
|
||||||
['第4节', '自习', '自习', '自习', '自习', '自习'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
### 复杂合并示例:项目进度表
|
|
||||||
|
|
||||||
<MergeTable
|
|
||||||
headerRows={2}
|
|
||||||
striped
|
|
||||||
client:load
|
|
||||||
data={[
|
|
||||||
[
|
|
||||||
Cell({ children: '项目阶段', header: true, rowspan: 2, align: 'center' }),
|
|
||||||
Cell({ children: '任务详情', header: true, colspan: 3, align: 'center' }),
|
|
||||||
Cell({ children: '状态', header: true, rowspan: 2, align: 'center' }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
Cell({ children: '任务名称', header: true }),
|
|
||||||
Cell({ children: '负责人', header: true }),
|
|
||||||
Cell({ children: '截止日期', header: true }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
Cell({ children: '需求分析', rowspan: 2, align: 'center'}),
|
|
||||||
'用户调研',
|
|
||||||
'张三',
|
|
||||||
'2024-02-01',
|
|
||||||
Cell({ children: '✅ 已完成'}),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'需求文档',
|
|
||||||
'李四',
|
|
||||||
'2024-02-15',
|
|
||||||
Cell({ children: '✅ 已完成'}),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
Cell({ children: '开发阶段', rowspan: 3, align: 'center'}),
|
|
||||||
'前端开发',
|
|
||||||
'王五',
|
|
||||||
'2024-03-01',
|
|
||||||
Cell({ children: '🔄 进行中'}),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'后端开发',
|
|
||||||
'赵六',
|
|
||||||
'2024-03-15',
|
|
||||||
Cell({ children: '🔄 进行中'}),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'数据库设计',
|
|
||||||
'钱七',
|
|
||||||
'2024-02-28',
|
|
||||||
Cell({ children: '✅ 已完成'}),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
Cell({ children: '测试上线', rowspan: 2, align: 'center'}),
|
|
||||||
'功能测试',
|
|
||||||
'孙八',
|
|
||||||
'2024-04-01',
|
|
||||||
Cell({ children: '⏳ 待开始'}),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'部署上线',
|
|
||||||
'周九',
|
|
||||||
'2024-04-15',
|
|
||||||
Cell({ children: '⏳ 待开始'}),
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
### 简洁语法示例
|
|
||||||
|
|
||||||
你也可以使用更简洁的语法,直接传入二维数组:
|
|
||||||
|
|
||||||
<MergeTable
|
|
||||||
headerRows={1}
|
|
||||||
client:load
|
|
||||||
data={[
|
|
||||||
['姓名', '年龄', '城市', '职业'],
|
|
||||||
['张三', '28', '北京', '工程师'],
|
|
||||||
['李四', '32', '上海', '设计师'],
|
|
||||||
['王五', '25', '广州', '产品经理'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
### 使用方式
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import MergeTable, { Cell } from '../../components/react/MergeTable';
|
|
||||||
|
|
||||||
// 使用 Cell 函数定义合并单元格
|
|
||||||
<MergeTable
|
|
||||||
headerRows={1} // 前几行作为表头
|
|
||||||
bordered={true} // 显示边框(默认 true)
|
|
||||||
striped={false} // 斑马纹(默认 false)
|
|
||||||
hoverable={true} // 悬停效果(默认 true)
|
|
||||||
compact={false} // 紧凑模式(默认 false)
|
|
||||||
client:load
|
|
||||||
data={[
|
|
||||||
[
|
|
||||||
Cell({ children: '标题', header: true, colspan: 2 }),
|
|
||||||
Cell({ children: '操作', header: true }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
Cell({ children: '合并内容', colspan: 2 }),
|
|
||||||
'编辑',
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cell 函数属性**:
|
|
||||||
- `children`:单元格内容
|
|
||||||
- `colspan`:跨列数
|
|
||||||
- `rowspan`:跨行数
|
|
||||||
- `align`:对齐方式(`'left' | 'center' | 'right'`)
|
|
||||||
- `header`:是否为表头单元格(使用 `<th>` 标签)
|
|
||||||
- `style`:自定义样式
|
|
||||||
- `className`:自定义 CSS 类名
|
|
||||||
|
|
||||||
**MergeTable 属性**:
|
|
||||||
- `data`:表格数据(二维数组)
|
|
||||||
- `headerRows`:表头行数(这些行会使用 `<th>` 标签)
|
|
||||||
- `bordered`:是否显示边框
|
|
||||||
- `striped`:是否显示斑马纹
|
|
||||||
- `hoverable`:是否启用悬停效果
|
|
||||||
- `compact`:紧凑模式(更小的内边距)
|
|
||||||
|
|
||||||
## 📝 如何在文章中使用
|
|
||||||
|
|
||||||
### 1. 导入组件
|
|
||||||
|
|
||||||
在文章顶部添加 import 语句:
|
|
||||||
|
|
||||||
```mdx
|
|
||||||
import AnimatedCard from '../../components/react/AnimatedCard';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 使用组件
|
|
||||||
|
|
||||||
```mdx
|
|
||||||
<AnimatedCard
|
|
||||||
title="标题"
|
|
||||||
description="描述"
|
|
||||||
color="#3b82f6"
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. client 指令说明
|
|
||||||
|
|
||||||
| 指令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `client:load` | 页面加载时立即激活组件 |
|
|
||||||
| `client:visible` | 组件进入视口时激活 |
|
|
||||||
| `client:idle` | 浏览器空闲时激活 |
|
|
||||||
| `client:media="(min-width: 768px)"` | 满足媒体查询时激活 |
|
|
||||||
|
|
||||||
## 🎨 创建自定义组件
|
|
||||||
|
|
||||||
你可以在 `src/components/react/` 目录下创建自己的 React 组件:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// src/components/react/MyComponent.tsx
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface MyComponentProps {
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MyComponent({ title }: MyComponentProps) {
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '1rem', background: '#f3f4f6' }}>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
<button onClick={() => setCount(count + 1)}>
|
|
||||||
点击次数: {count}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
然后在文章中使用:
|
|
||||||
|
|
||||||
```mdx
|
|
||||||
import MyComponent from '../../components/react/MyComponent';
|
|
||||||
|
|
||||||
<MyComponent title="我的组件" client:load />
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 总结
|
|
||||||
|
|
||||||
NovaBlog 提供了灵活的组件系统,让你可以在 Markdown 中嵌入丰富的交互内容。通过 React 组件,你可以实现:
|
|
||||||
|
|
||||||
- 🎴 **视觉效果**:悬浮、翻转、渐变等动画
|
|
||||||
- ⌨️ **动态文字**:打字机、滚动、闪烁效果
|
|
||||||
- ✨ **背景特效**:粒子、波浪、光效
|
|
||||||
- 🎮 **交互功能**:计数器、表单、游戏
|
|
||||||
- 📐 **数学公式**:翻转卡片展示 LaTeX 公式
|
|
||||||
- 📊 **高级表格**:支持合并单元格的复杂表格
|
|
||||||
|
|
||||||
快去尝试创建属于你自己的动效组件吧! 🚀
|
|
||||||
@@ -22,19 +22,6 @@ const blogCollection = defineCollection({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 页面集合 (如关于页面等)
|
|
||||||
const pagesCollection = defineCollection({
|
|
||||||
type: 'content',
|
|
||||||
schema: z.object({
|
|
||||||
title: z.string(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
layout: z.string().optional(),
|
|
||||||
showInNav: z.boolean().default(false),
|
|
||||||
order: z.number().default(0),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const collections = {
|
export const collections = {
|
||||||
blog: blogCollection,
|
blog: blogCollection,
|
||||||
pages: pagesCollection,
|
|
||||||
};
|
};
|
||||||
@@ -112,7 +112,6 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
|
|||||||
<div class="hidden md:flex items-center gap-6">
|
<div class="hidden md:flex items-center gap-6">
|
||||||
<a href="/" class="text-foreground/70 hover:text-foreground transition-colors">首页</a>
|
<a href="/" class="text-foreground/70 hover:text-foreground transition-colors">首页</a>
|
||||||
<a href="/blog" class="text-foreground/70 hover:text-foreground transition-colors">博客</a>
|
<a href="/blog" class="text-foreground/70 hover:text-foreground transition-colors">博客</a>
|
||||||
<a href="/micro" class="text-foreground/70 hover:text-foreground transition-colors">微语</a>
|
|
||||||
<a href="/categories" class="text-foreground/70 hover:text-foreground transition-colors">分类</a>
|
<a href="/categories" class="text-foreground/70 hover:text-foreground transition-colors">分类</a>
|
||||||
<a href="/tags" class="text-foreground/70 hover:text-foreground transition-colors">标签</a>
|
<a href="/tags" class="text-foreground/70 hover:text-foreground transition-colors">标签</a>
|
||||||
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors">关于</a>
|
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors">关于</a>
|
||||||
@@ -159,7 +158,6 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
|
|||||||
<div class="content-width py-4 flex flex-col gap-3">
|
<div class="content-width py-4 flex flex-col gap-3">
|
||||||
<a href="/" class="text-foreground/70 hover:text-foreground transition-colors py-2">首页</a>
|
<a href="/" class="text-foreground/70 hover:text-foreground transition-colors py-2">首页</a>
|
||||||
<a href="/blog" class="text-foreground/70 hover:text-foreground transition-colors py-2">博客</a>
|
<a href="/blog" class="text-foreground/70 hover:text-foreground transition-colors py-2">博客</a>
|
||||||
<a href="/micro" class="text-foreground/70 hover:text-foreground transition-colors py-2">微语</a>
|
|
||||||
<a href="/categories" class="text-foreground/70 hover:text-foreground transition-colors py-2">分类</a>
|
<a href="/categories" class="text-foreground/70 hover:text-foreground transition-colors py-2">分类</a>
|
||||||
<a href="/tags" class="text-foreground/70 hover:text-foreground transition-colors py-2">标签</a>
|
<a href="/tags" class="text-foreground/70 hover:text-foreground transition-colors py-2">标签</a>
|
||||||
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors py-2">关于</a>
|
<a href="/about" class="text-foreground/70 hover:text-foreground transition-colors py-2">关于</a>
|
||||||
@@ -189,7 +187,6 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
|
|||||||
<h3 class="font-semibold text-lg mb-4">快速链接</h3>
|
<h3 class="font-semibold text-lg mb-4">快速链接</h3>
|
||||||
<ul class="space-y-2 text-sm">
|
<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="/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="/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="/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>
|
<li><a href="/rss.xml" class="text-foreground/60 hover:text-primary-500 transition-colors">RSS 订阅</a></li>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user