add tags and category pages, then update backend apis
This commit is contained in:
19
package-lock.json
generated
19
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "tidal-trappist",
|
"name": "novablog",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tidal-trappist",
|
"name": "novablog",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^4.3.13",
|
"@astrojs/mdx": "^4.3.13",
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"@astrojs/vue": "^5.1.4",
|
"@astrojs/vue": "^5.1.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"astro": "^5.17.1",
|
"astro": "^5.17.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"marked": "^17.0.3",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
"vue": "^3.5.29"
|
"vue": "^3.5.29"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4633,6 +4634,18 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/mdast-util-definitions": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"@astrojs/vue": "^5.1.4",
|
"@astrojs/vue": "^5.1.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"astro": "^5.17.1",
|
"astro": "^5.17.1",
|
||||||
|
"marked": "^17.0.3",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vue": "^3.5.29"
|
"vue": "^3.5.29"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/images/hello-world.jpg
Normal file
BIN
public/images/hello-world.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 730 KiB |
11
public/images/hello-world.svg
Normal file
11
public/images/hello-world.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="630" fill="url(#grad)"/>
|
||||||
|
<text x="600" y="280" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="white" text-anchor="middle">NovaBlog</text>
|
||||||
|
<text x="600" y="380" font-family="Arial, sans-serif" font-size="32" fill="white" text-anchor="middle" opacity="0.9">极简程序员博客系统</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 693 B |
@@ -65,8 +65,8 @@ func main() {
|
|||||||
// 公开接口
|
// 公开接口
|
||||||
api.POST("/auth/register", authHandler.Register)
|
api.POST("/auth/register", authHandler.Register)
|
||||||
api.POST("/auth/login", authHandler.Login)
|
api.POST("/auth/login", authHandler.Login)
|
||||||
|
api.GET("/auth/me", authHandler.GetCurrentUser) // 获取当前用户信息(需要 token 但通过 header 传递)
|
||||||
api.GET("/comments", commentHandler.GetComments)
|
api.GET("/comments", commentHandler.GetComments)
|
||||||
api.POST("/comments", commentHandler.CreateComment) // 允许访客评论
|
|
||||||
api.GET("/likes", likeHandler.GetLikeStatus)
|
api.GET("/likes", likeHandler.GetLikeStatus)
|
||||||
api.POST("/likes", likeHandler.ToggleLike) // 允许访客点赞(基于 IP Hash)
|
api.POST("/likes", likeHandler.ToggleLike) // 允许访客点赞(基于 IP Hash)
|
||||||
|
|
||||||
@@ -78,7 +78,8 @@ func main() {
|
|||||||
authGroup.GET("/auth/profile", authHandler.GetProfile)
|
authGroup.GET("/auth/profile", authHandler.GetProfile)
|
||||||
authGroup.PUT("/auth/profile", authHandler.UpdateProfile)
|
authGroup.PUT("/auth/profile", authHandler.UpdateProfile)
|
||||||
|
|
||||||
// 评论相关(用户删除自己的评论)
|
// 评论相关(需要登录才能评论)
|
||||||
|
authGroup.POST("/comments", commentHandler.CreateComment)
|
||||||
authGroup.DELETE("/comments/:id", commentHandler.DeleteComment)
|
authGroup.DELETE("/comments/:id", commentHandler.DeleteComment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/novablog/server/internal/config"
|
"github.com/novablog/server/internal/config"
|
||||||
"github.com/novablog/server/internal/models"
|
"github.com/novablog/server/internal/models"
|
||||||
|
"github.com/novablog/server/internal/utils"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
@@ -37,6 +38,11 @@ func Initialize(cfg *config.Config) error {
|
|||||||
return fmt.Errorf("failed to migrate database: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,4 +64,35 @@ func Close() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return sqlDB.Close()
|
return sqlDB.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 获取当前用户信息
|
// GetProfile 获取当前用户信息
|
||||||
func (h *AuthHandler) GetProfile(c *gin.Context) {
|
func (h *AuthHandler) GetProfile(c *gin.Context) {
|
||||||
userID, _ := middleware.GetUserID(c)
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
|||||||
@@ -28,14 +28,21 @@ type CreateCommentRequest struct {
|
|||||||
|
|
||||||
// CreateComment 创建评论
|
// CreateComment 创建评论
|
||||||
func (h *CommentHandler) CreateComment(c *gin.Context) {
|
func (h *CommentHandler) CreateComment(c *gin.Context) {
|
||||||
userID, _ := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req CreateCommentRequest
|
var req CreateCommentRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 尝试从上下文获取用户 ID(如果有认证中间件)
|
||||||
|
userID, isLoggedIn := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
// 如果没有登录,返回错误(评论需要登录)
|
||||||
|
if !isLoggedIn || userID == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后再评论"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
comment := models.Comment{
|
comment := models.Comment{
|
||||||
PostID: req.PostID,
|
PostID: req.PostID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -82,9 +89,11 @@ func (h *CommentHandler) GetComments(c *gin.Context) {
|
|||||||
|
|
||||||
query.Count(&total)
|
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").
|
Preload("Replies.User").
|
||||||
Order("created_at DESC").
|
Order("comments.created_at DESC").
|
||||||
Offset((page - 1) * pageSize).
|
Offset((page - 1) * pageSize).
|
||||||
Limit(pageSize).
|
Limit(pageSize).
|
||||||
Find(&comments).Error; err != nil {
|
Find(&comments).Error; err != nil {
|
||||||
@@ -92,6 +101,25 @@ func (h *CommentHandler) GetComments(c *gin.Context) {
|
|||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"data": comments,
|
"data": comments,
|
||||||
"pagination": gin.H{
|
"pagination": gin.H{
|
||||||
|
|||||||
BIN
server/novablog-server
Executable file
BIN
server/novablog-server
Executable file
Binary file not shown.
@@ -45,7 +45,7 @@
|
|||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
<div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||||
<span class="text-primary-600 dark:text-primary-400 font-medium">
|
<span class="text-primary-600 dark:text-primary-400 font-medium">
|
||||||
{{ (comment.user.nickname || comment.user.username)[0].toUpperCase() }}
|
{{ getInitial(comment.user) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,10 +53,10 @@
|
|||||||
<!-- 内容 -->
|
<!-- 内容 -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<span class="font-medium">{{ comment.user.nickname || comment.user.username }}</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>
|
||||||
<p class="text-foreground/80 whitespace-pre-wrap">{{ comment.content }}</p>
|
<div class="comment-content prose prose-sm dark:prose-invert max-w-none" v-html="renderMarkdown(comment.content)"></div>
|
||||||
|
|
||||||
<!-- 回复按钮 -->
|
<!-- 回复按钮 -->
|
||||||
<button
|
<button
|
||||||
@@ -94,16 +94,16 @@
|
|||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||||
<span class="text-primary-600 dark:text-primary-400 text-sm font-medium">
|
<span class="text-primary-600 dark:text-primary-400 text-sm font-medium">
|
||||||
{{ (reply.user.nickname || reply.user.username)[0].toUpperCase() }}
|
{{ getInitial(reply.user) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<span class="font-medium text-sm">{{ reply.user.nickname || reply.user.username }}</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>
|
||||||
<p class="text-foreground/80 text-sm">{{ reply.content }}</p>
|
<div class="comment-content prose prose-sm dark:prose-invert max-w-none text-sm" v-html="renderMarkdown(reply.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,6 +135,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
// 配置 marked 选项 - 安全模式,禁止 HTML 标签
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true, // 支持 GFM 换行
|
||||||
|
gfm: true, // GitHub Flavored Markdown
|
||||||
|
});
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
postId: string;
|
postId: string;
|
||||||
@@ -179,11 +186,12 @@ async function loadComments() {
|
|||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
comments.value = data.data;
|
comments.value = Array.isArray(data.data) ? data.data : [];
|
||||||
pagination.value = data.pagination;
|
pagination.value = data.pagination || { page: 1, pageSize: 20, total: 0, totalPage: 0 };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load comments:', error);
|
console.error('Failed to load comments:', error);
|
||||||
|
comments.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -263,6 +271,19 @@ function loadPage(page: number) {
|
|||||||
loadComments();
|
loadComments();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取用户首字母
|
||||||
|
function getInitial(user: any): string {
|
||||||
|
if (!user) return '?';
|
||||||
|
const name = user.nickname || user.username || '匿名';
|
||||||
|
return name[0].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户显示名称
|
||||||
|
function getDisplayName(user: any): string {
|
||||||
|
if (!user) return '匿名用户';
|
||||||
|
return user.nickname || user.username || '匿名用户';
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
function formatDate(dateString: string) {
|
function formatDate(dateString: string) {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
@@ -285,6 +306,18 @@ function formatDate(dateString: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 渲染 Markdown 内容(安全模式)
|
||||||
|
function renderMarkdown(content: string): string {
|
||||||
|
if (!content) return '';
|
||||||
|
try {
|
||||||
|
// 使用 marked 解析 Markdown,返回 HTML 字符串
|
||||||
|
return marked.parse(content) as string;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse markdown:', error);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadComments();
|
loadComments();
|
||||||
});
|
});
|
||||||
@@ -294,4 +327,102 @@ onMounted(() => {
|
|||||||
.comment-section {
|
.comment-section {
|
||||||
@apply mt-12 pt-8 border-t border-border;
|
@apply mt-12 pt-8 border-t border-border;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
/* 评论内容 Markdown 样式 */
|
||||||
|
.comment-content :deep(p) {
|
||||||
|
@apply my-2 leading-relaxed text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(p:first-child) {
|
||||||
|
@apply mt-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(p:last-child) {
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(a) {
|
||||||
|
@apply text-primary-500 hover:underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(strong) {
|
||||||
|
@apply font-bold text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(em) {
|
||||||
|
@apply italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(code) {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm font-mono text-primary-600 dark:text-primary-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(pre) {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto my-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(pre code) {
|
||||||
|
@apply bg-transparent p-0 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(blockquote) {
|
||||||
|
@apply border-l-4 border-primary-500 pl-4 my-3 italic text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(ul),
|
||||||
|
.comment-content :deep(ol) {
|
||||||
|
@apply my-2 pl-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(ul) {
|
||||||
|
@apply list-disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(ol) {
|
||||||
|
@apply list-decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(li) {
|
||||||
|
@apply my-1 text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(h1),
|
||||||
|
.comment-content :deep(h2),
|
||||||
|
.comment-content :deep(h3),
|
||||||
|
.comment-content :deep(h4) {
|
||||||
|
@apply font-bold text-gray-900 dark:text-gray-100 mt-4 mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(h1) {
|
||||||
|
@apply text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(h2) {
|
||||||
|
@apply text-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(h3) {
|
||||||
|
@apply text-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(hr) {
|
||||||
|
@apply border-gray-200 dark:border-gray-700 my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(img) {
|
||||||
|
@apply max-w-full rounded-lg my-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(table) {
|
||||||
|
@apply w-full border-collapse my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(th),
|
||||||
|
.comment-content :deep(td) {
|
||||||
|
@apply border border-gray-200 dark:border-gray-700 px-3 py-2 text-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content :deep(th) {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-800 font-bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
83
src/components/UserStatus.vue
Normal file
83
src/components/UserStatus.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<template v-if="isLoggedIn && user">
|
||||||
|
<span class="text-sm text-foreground/70">你好, {{ user.nickname || user.username }}</span>
|
||||||
|
<button
|
||||||
|
@click="handleLogout"
|
||||||
|
class="btn-ghost px-3 py-1.5 rounded-lg text-sm font-medium text-red-500 hover:text-red-600"
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a href="/login" class="btn-ghost px-3 py-1.5 rounded-lg text-sm font-medium">
|
||||||
|
登录
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
email: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoggedIn = ref(false)
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.PUBLIC_API_BASE || 'http://localhost:8080'
|
||||||
|
|
||||||
|
// 检查登录状态
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
isLoggedIn.value = false
|
||||||
|
user.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
user.value = data.user
|
||||||
|
isLoggedIn.value = true
|
||||||
|
} else {
|
||||||
|
// Token 无效,清除
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
isLoggedIn.value = false
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check auth:', error)
|
||||||
|
isLoggedIn.value = false
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
isLoggedIn.value = false
|
||||||
|
user.value = null
|
||||||
|
// 刷新页面
|
||||||
|
window.location.href = '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkAuth()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
|
import UserStatus from '../components/UserStatus.vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -104,16 +105,15 @@ 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="/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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Login Link -->
|
<!-- User Status (登录状态) -->
|
||||||
<a href="/login" class="btn-ghost px-3 py-1.5 rounded-lg text-sm font-medium">
|
<UserStatus client:load />
|
||||||
登录
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Theme Toggle -->
|
<!-- Theme Toggle -->
|
||||||
<button
|
<button
|
||||||
@@ -151,6 +151,7 @@ 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="/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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
265
src/pages/categories/index.astro
Normal file
265
src/pages/categories/index.astro
Normal file
@@ -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<string, CategoryNode>;
|
||||||
|
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 += `
|
||||||
|
<div class="category-group level-${level}">
|
||||||
|
<div class="category-header flex items-center justify-between p-4 rounded-lg cursor-pointer hover:bg-muted/50 transition-colors" data-category="${node.fullName}">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
${hasChildren || hasPosts ? `
|
||||||
|
<button class="category-toggle p-1 hover:bg-muted rounded transition-colors">
|
||||||
|
<svg class="w-4 h-4 chevron text-foreground/40 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
` : '<div class="w-6"></div>'}
|
||||||
|
<svg class="w-5 h-5 text-primary-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">${node.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-foreground/40 bg-muted px-2.5 py-1 rounded-full">
|
||||||
|
${node.count} 篇
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子内容
|
||||||
|
if (hasChildren || hasPosts) {
|
||||||
|
const paddingClass = node.name === 'root' ? '' : 'category-content hidden';
|
||||||
|
html += `<div class="${paddingClass}">`;
|
||||||
|
|
||||||
|
// 文章列表
|
||||||
|
if (hasPosts) {
|
||||||
|
html += `<div class="post-list py-2">`;
|
||||||
|
node.posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()).forEach((post) => {
|
||||||
|
const slug = post.slug || post.id;
|
||||||
|
html += `
|
||||||
|
<a href="/blog/${slug}" class="post-item flex items-center justify-between px-4 py-3 rounded-lg hover:bg-muted/30 transition-colors">
|
||||||
|
<span class="text-foreground/80 hover:text-primary-500 transition-colors">${post.data.title}</span>
|
||||||
|
<span class="text-xs text-foreground/40">${formatDate(post.data.pubDate)}</span>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子分类
|
||||||
|
sortedChildren.forEach((child) => {
|
||||||
|
html += renderCategoryNode(child, level + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.name !== 'root') {
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title="分类 - NovaBlog">
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="content-width">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h1 class="text-3xl md:text-4xl font-bold mb-4">文章分类</h1>
|
||||||
|
<p class="text-foreground/60">
|
||||||
|
共 {totalCategories} 个分类,{allPosts.length} 篇文章
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categoryTree.children.size > 0 || categoryTree.posts.length > 0 ? (
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<!-- 未分类文章 -->
|
||||||
|
{categoryTree.posts.length > 0 && (
|
||||||
|
<div class="category-group mb-6">
|
||||||
|
<div class="category-header flex items-center justify-between p-4 bg-muted/30 rounded-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-foreground/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">未分类</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-foreground/40 bg-muted px-2 py-1 rounded-full">
|
||||||
|
{categoryTree.posts.length} 篇
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- 分类列表 -->
|
||||||
|
<div class="category-tree" set:html={renderCategoryNode(categoryTree, 0)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="text-center py-16 text-foreground/40">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
<p>暂无分类</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 客户端交互:折叠/展开分类
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.category-header').forEach((header) => {
|
||||||
|
header.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
// 只有点击头部或按钮时才切换
|
||||||
|
if (target.closest('.post-item')) return;
|
||||||
|
|
||||||
|
const group = header.closest('.category-group');
|
||||||
|
const content = group?.querySelector('.category-content') as HTMLElement;
|
||||||
|
const chevron = header.querySelector('.chevron');
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
content.classList.toggle('hidden');
|
||||||
|
chevron?.classList.toggle('rotate-90');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.category-group {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header {
|
||||||
|
transition: all 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-content {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-0 > .category-header {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-1 > .category-header {
|
||||||
|
background: color-mix(in srgb, var(--muted) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-2 > .category-header {
|
||||||
|
background: color-mix(in srgb, var(--muted) 30%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,26 +17,72 @@ allPosts.forEach((post) => {
|
|||||||
|
|
||||||
// 按文章数量排序
|
// 按文章数量排序
|
||||||
const sortedTags = [...tagMap.entries()].sort((a, b) => b[1] - a[1]);
|
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];
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="标签 - NovaBlog">
|
<BaseLayout title="标签云 - NovaBlog">
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
<div class="content-width">
|
<div class="content-width">
|
||||||
<div class="text-center mb-12">
|
<div class="text-center mb-12">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold mb-4">标签</h1>
|
<h1 class="text-3xl md:text-4xl font-bold mb-4">标签云</h1>
|
||||||
<p class="text-foreground/60">按标签浏览文章</p>
|
<p class="text-foreground/60">
|
||||||
|
共 {sortedTags.length} 个标签,{allPosts.length} 篇文章
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedTags.length > 0 ? (
|
{sortedTags.length > 0 ? (
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="tag-cloud">
|
||||||
<div class="flex flex-wrap justify-center gap-4">
|
<div class="flex flex-wrap justify-center items-center gap-x-4 gap-y-6 max-w-4xl mx-auto px-4">
|
||||||
{sortedTags.map(([tag, count]) => (
|
{sortedTags.map(([tag, count], index) => (
|
||||||
<a
|
<a
|
||||||
href={`/tags/${tag}`}
|
href={`/tags/${tag}`}
|
||||||
class="card px-6 py-4 flex items-center gap-3 hover:border-primary-500 transition-colors"
|
class="tag-item hover:scale-110 transition-transform duration-300"
|
||||||
|
style={`font-size: ${getFontSize(count)}; opacity: ${getOpacity(count)}`}
|
||||||
>
|
>
|
||||||
<span class="text-lg font-medium">#{tag}</span>
|
<span class={`font-medium ${getRandomColor(index)}`}>
|
||||||
<span class="text-sm text-foreground/40">{count} 篇文章</span>
|
#{tag}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-foreground/40 ml-1">({count})</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +95,37 @@ const sortedTags = [...tagMap.entries()].sort((a, b) => b[1] - a[1]);
|
|||||||
<p>暂无标签</p>
|
<p>暂无标签</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<!-- 图例说明 -->
|
||||||
|
<div class="mt-16 text-center">
|
||||||
|
<div class="inline-flex items-center gap-6 text-sm text-foreground/40">
|
||||||
|
<span>字体大小 = 文章数量</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>点击标签查看相关文章</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tag-cloud {
|
||||||
|
min-height: 300px;
|
||||||
|
background: linear-gradient(135deg, transparent 0%, var(--muted) 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user