diff --git a/package-lock.json b/package-lock.json index 388f5df..fea6885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "tidal-trappist", + "name": "novablog", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tidal-trappist", + "name": "novablog", "version": "0.0.1", "dependencies": { "@astrojs/mdx": "^4.3.13", @@ -13,7 +13,8 @@ "@astrojs/vue": "^5.1.4", "@tailwindcss/typography": "^0.5.19", "astro": "^5.17.1", - "tailwindcss": "^3.4.19", + "marked": "^17.0.3", + "tailwindcss": "^3.4.0", "vue": "^3.5.29" } }, @@ -4633,6 +4634,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", + "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mdast-util-definitions": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", diff --git a/package.json b/package.json index 350f398..12ea264 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "@astrojs/vue": "^5.1.4", "@tailwindcss/typography": "^0.5.19", "astro": "^5.17.1", + "marked": "^17.0.3", "tailwindcss": "^3.4.0", "vue": "^3.5.29" } -} \ No newline at end of file +} diff --git a/public/images/hello-world.jpg b/public/images/hello-world.jpg new file mode 100644 index 0000000..7df0e0c Binary files /dev/null and b/public/images/hello-world.jpg differ diff --git a/public/images/hello-world.svg b/public/images/hello-world.svg new file mode 100644 index 0000000..13841bc --- /dev/null +++ b/public/images/hello-world.svg @@ -0,0 +1,11 @@ + + + + + + + + + NovaBlog + 极简程序员博客系统 + diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index dfa43fe..8714f12 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -65,8 +65,8 @@ func main() { // 公开接口 api.POST("/auth/register", authHandler.Register) api.POST("/auth/login", authHandler.Login) + api.GET("/auth/me", authHandler.GetCurrentUser) // 获取当前用户信息(需要 token 但通过 header 传递) api.GET("/comments", commentHandler.GetComments) - api.POST("/comments", commentHandler.CreateComment) // 允许访客评论 api.GET("/likes", likeHandler.GetLikeStatus) api.POST("/likes", likeHandler.ToggleLike) // 允许访客点赞(基于 IP Hash) @@ -78,7 +78,8 @@ func main() { authGroup.GET("/auth/profile", authHandler.GetProfile) authGroup.PUT("/auth/profile", authHandler.UpdateProfile) - // 评论相关(用户删除自己的评论) + // 评论相关(需要登录才能评论) + authGroup.POST("/comments", commentHandler.CreateComment) authGroup.DELETE("/comments/:id", commentHandler.DeleteComment) } diff --git a/server/internal/database/database.go b/server/internal/database/database.go index 460265b..c7da001 100644 --- a/server/internal/database/database.go +++ b/server/internal/database/database.go @@ -7,6 +7,7 @@ import ( "github.com/novablog/server/internal/config" "github.com/novablog/server/internal/models" + "github.com/novablog/server/internal/utils" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -37,6 +38,11 @@ func Initialize(cfg *config.Config) error { return fmt.Errorf("failed to migrate database: %w", err) } + // 创建默认管理员用户 + if err := createDefaultAdmin(); err != nil { + return fmt.Errorf("failed to create default admin: %w", err) + } + return nil } @@ -58,4 +64,35 @@ func Close() error { return err } return sqlDB.Close() -} \ No newline at end of file +} + +// createDefaultAdmin 创建默认管理员用户 +func createDefaultAdmin() error { + // 检查是否已存在 admin 用户 + var count int64 + DB.Model(&models.User{}).Where("username = ?", "admin").Count(&count) + if count > 0 { + return nil // 已存在,跳过 + } + + // 创建 admin 用户 + hashedPassword, err := utils.HashPassword("admin") + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + admin := models.User{ + Username: "admin", + Email: "admin@novablog.local", + Password: hashedPassword, + Role: "admin", + Nickname: "Administrator", + } + + if err := DB.Create(&admin).Error; err != nil { + return fmt.Errorf("failed to create admin user: %w", err) + } + + fmt.Println("✅ Default admin user created: admin / admin") + return nil +} diff --git a/server/internal/handlers/auth.go b/server/internal/handlers/auth.go index 6760ac8..72b5ef7 100644 --- a/server/internal/handlers/auth.go +++ b/server/internal/handlers/auth.go @@ -136,6 +136,45 @@ func (h *AuthHandler) Login(c *gin.Context) { }) } +// GetCurrentUser 获取当前用户信息(通过 Authorization header) +func (h *AuthHandler) GetCurrentUser(c *gin.Context) { + // 从 header 获取 token + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "no token provided"}) + return + } + + // 解析 Bearer token + tokenString := "" + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + tokenString = authHeader[7:] + } else { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token format"}) + return + } + + // 验证 token + claims, err := h.jwtManager.ParseToken(tokenString) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + // 获取用户信息 + var user models.User + if err := database.DB.First(&user, claims.UserID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"user": user}) +} + // GetProfile 获取当前用户信息 func (h *AuthHandler) GetProfile(c *gin.Context) { userID, _ := middleware.GetUserID(c) diff --git a/server/internal/handlers/comment.go b/server/internal/handlers/comment.go index d7b75d1..533056c 100644 --- a/server/internal/handlers/comment.go +++ b/server/internal/handlers/comment.go @@ -28,14 +28,21 @@ type CreateCommentRequest struct { // CreateComment 创建评论 func (h *CommentHandler) CreateComment(c *gin.Context) { - userID, _ := middleware.GetUserID(c) - var req CreateCommentRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // 尝试从上下文获取用户 ID(如果有认证中间件) + userID, isLoggedIn := middleware.GetUserID(c) + + // 如果没有登录,返回错误(评论需要登录) + if !isLoggedIn || userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后再评论"}) + return + } + comment := models.Comment{ PostID: req.PostID, UserID: userID, @@ -82,9 +89,11 @@ func (h *CommentHandler) GetComments(c *gin.Context) { query.Count(&total) - if err := query.Preload("User"). + // 使用 Joins 显式加载用户信息,确保数据完整 + if err := query. + Joins("LEFT JOIN users ON users.id = comments.user_id"). Preload("Replies.User"). - Order("created_at DESC"). + Order("comments.created_at DESC"). Offset((page - 1) * pageSize). Limit(pageSize). Find(&comments).Error; err != nil { @@ -92,6 +101,25 @@ func (h *CommentHandler) GetComments(c *gin.Context) { return } + // 手动加载每个评论的用户信息(确保 Preload 正确工作) + for i := range comments { + if comments[i].UserID > 0 { + var user models.User + if err := database.DB.First(&user, comments[i].UserID).Error; err == nil { + comments[i].User = user + } + } + // 加载回复的用户信息 + for j := range comments[i].Replies { + if comments[i].Replies[j].UserID > 0 { + var replyUser models.User + if err := database.DB.First(&replyUser, comments[i].Replies[j].UserID).Error; err == nil { + comments[i].Replies[j].User = replyUser + } + } + } + } + c.JSON(http.StatusOK, gin.H{ "data": comments, "pagination": gin.H{ diff --git a/server/novablog-server b/server/novablog-server new file mode 100755 index 0000000..5f98314 Binary files /dev/null and b/server/novablog-server differ diff --git a/src/components/CommentSection.vue b/src/components/CommentSection.vue index 0c3cddd..c03ec44 100644 --- a/src/components/CommentSection.vue +++ b/src/components/CommentSection.vue @@ -45,7 +45,7 @@
- {{ (comment.user.nickname || comment.user.username)[0].toUpperCase() }} + {{ getInitial(comment.user) }}
@@ -53,10 +53,10 @@
- {{ comment.user.nickname || comment.user.username }} + {{ getDisplayName(comment.user) }} {{ formatDate(comment.created_at) }}
-

{{ comment.content }}

+
- {{ reply.user.nickname || reply.user.username }} + {{ getDisplayName(reply.user) }} {{ formatDate(reply.created_at) }}
-

{{ reply.content }}

+
@@ -135,6 +135,13 @@ \ No newline at end of file diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 1a765c0..0741e30 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,5 +1,6 @@ --- import '../styles/global.css'; +import UserStatus from '../components/UserStatus.vue'; interface Props { title: string; @@ -104,16 +105,15 @@ const socialImageURL = image.startsWith('http') ? image : new URL(image, site).h
- - - 登录 - + +
diff --git a/src/pages/categories/index.astro b/src/pages/categories/index.astro new file mode 100644 index 0000000..cd0b1dd --- /dev/null +++ b/src/pages/categories/index.astro @@ -0,0 +1,265 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import { getCollection } from 'astro:content'; + +// 获取所有文章 +const allPosts = await getCollection('blog', ({ data }) => { + return !data.draft; +}); + +// 构建分类树结构 +interface CategoryNode { + name: string; + fullName: string; + count: number; + children: Map; + posts: typeof allPosts; +} + +function buildCategoryTree(posts: typeof allPosts): CategoryNode { + const root: CategoryNode = { + name: 'root', + fullName: '', + count: 0, + children: new Map(), + posts: [], + }; + + posts.forEach((post) => { + const category = post.data.category; + if (!category) { + root.posts.push(post); + root.count++; + return; + } + + // 支持多级分类,用 / 分隔,如 "技术/前端/React" + const parts = category.split('/').map((p) => p.trim()); + let current = root; + + parts.forEach((part, index) => { + const fullName = parts.slice(0, index + 1).join('/'); + if (!current.children.has(part)) { + current.children.set(part, { + name: part, + fullName: fullName, + count: 0, + children: new Map(), + posts: [], + }); + } + const node = current.children.get(part)!; + node.count++; + + // 如果是最后一级,添加文章 + if (index === parts.length - 1) { + node.posts.push(post); + } + + current = node; + }); + }); + + return root; +} + +const categoryTree = buildCategoryTree(allPosts); + +// 统计总数 +const totalCategories = (() => { + let count = 0; + function countNodes(node: CategoryNode) { + count += node.children.size; + node.children.forEach((child) => countNodes(child)); + } + countNodes(categoryTree); + return count; +})(); + +// 格式化日期 +function formatDate(date: Date): string { + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +// 递归渲染分类节点 +function renderCategoryNode(node: CategoryNode, level: number): string { + const sortedChildren = [...node.children.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')); + const hasChildren = sortedChildren.length > 0; + const hasPosts = node.posts.length > 0; + + let html = ''; + + if (node.name !== 'root') { + html += ` +
+
+
+ ${hasChildren || hasPosts ? ` + + ` : '
'} + + + + ${node.name} +
+ + ${node.count} 篇 + +
+ `; + } + + // 子内容 + if (hasChildren || hasPosts) { + const paddingClass = node.name === 'root' ? '' : 'category-content hidden'; + html += `
`; + + // 文章列表 + if (hasPosts) { + html += `
`; + node.posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()).forEach((post) => { + const slug = post.slug || post.id; + html += ` + + ${post.data.title} + ${formatDate(post.data.pubDate)} + + `; + }); + html += `
`; + } + + // 子分类 + sortedChildren.forEach((child) => { + html += renderCategoryNode(child, level + 1); + }); + + html += `
`; + } + + if (node.name !== 'root') { + html += `
`; + } + + return html; +} +--- + + +
+
+
+

文章分类

+

+ 共 {totalCategories} 个分类,{allPosts.length} 篇文章 +

+
+ + {categoryTree.children.size > 0 || categoryTree.posts.length > 0 ? ( +
+ + {categoryTree.posts.length > 0 && ( +
+
+
+ + + + 未分类 +
+ + {categoryTree.posts.length} 篇 + +
+
+ )} + + +
+
+ ) : ( +
+ + + +

暂无分类

+
+ )} +
+
+ + + + + \ No newline at end of file diff --git a/src/pages/tags/index.astro b/src/pages/tags/index.astro index 29e7601..f5e6106 100644 --- a/src/pages/tags/index.astro +++ b/src/pages/tags/index.astro @@ -17,26 +17,72 @@ allPosts.forEach((post) => { // 按文章数量排序 const sortedTags = [...tagMap.entries()].sort((a, b) => b[1] - a[1]); + +// 计算字体大小(基于文章数量) +const maxCount = Math.max(...tagMap.values(), 1); +const minCount = Math.min(...tagMap.values(), 1); + +function getFontSize(count: number): string { + // 字体大小范围:0.875rem 到 2.5rem + const minSize = 0.875; + const maxSize = 2.5; + + if (maxCount === minCount) { + return `${(minSize + maxSize) / 2}rem`; + } + + const scale = (count - minCount) / (maxCount - minCount); + const size = minSize + scale * (maxSize - minSize); + return `${size}rem`; +} + +function getOpacity(count: number): number { + // 透明度范围:0.6 到 1 + if (maxCount === minCount) return 0.8; + const scale = (count - minCount) / (maxCount - minCount); + return 0.6 + scale * 0.4; +} + +// 随机颜色类 +const colorClasses = [ + 'text-primary-500', + 'text-blue-500', + 'text-purple-500', + 'text-pink-500', + 'text-indigo-500', + 'text-cyan-500', + 'text-teal-500', + 'text-emerald-500', +]; + +function getRandomColor(index: number): string { + return colorClasses[index % colorClasses.length]; +} --- - +
-

标签

-

按标签浏览文章

+

标签云

+

+ 共 {sortedTags.length} 个标签,{allPosts.length} 篇文章 +

{sortedTags.length > 0 ? ( -
-
- {sortedTags.map(([tag, count]) => ( +
+
+ {sortedTags.map(([tag, count], index) => ( - #{tag} - {count} 篇文章 + + #{tag} + + ({count}) ))}
@@ -49,6 +95,37 @@ const sortedTags = [...tagMap.entries()].sort((a, b) => b[1] - a[1]);

暂无标签

)} + + +
+
+ 字体大小 = 文章数量 + | + 点击标签查看相关文章 +
+
- \ No newline at end of file + + + \ No newline at end of file