数据结构优化
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<section id="blog-detail" class="page-section block animate-slide-down">
|
||||
<div class="max-w-4xl mx-auto pt-32 px-6 pb-20 relative">
|
||||
<div class="max-w-7xl mx-auto pt-32 px-6 pb-20 relative">
|
||||
<!-- 返回按钮 -->
|
||||
<button
|
||||
@click="$router.push('/blog')"
|
||||
@@ -27,71 +27,171 @@
|
||||
</div>
|
||||
|
||||
<!-- Blog detail content -->
|
||||
<div v-else class="space-y-12 relative">
|
||||
<!-- 目录侧边栏 (仅在 XL 屏幕显示) -->
|
||||
<aside class="hidden xl:block absolute left-full ml-12 top-0 h-full">
|
||||
<div class="sticky top-32 w-64 p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="list" class="w-4 h-4 text-art-accent"></i>
|
||||
目录导航
|
||||
</h3>
|
||||
<nav class="space-y-1 relative">
|
||||
<!-- 激活状态的指示条 -->
|
||||
<div
|
||||
class="absolute left-0 w-0.5 bg-art-accent transition-all duration-300 ease-out"
|
||||
:style="activeIndicatorStyle"
|
||||
v-if="toc.length > 0"
|
||||
></div>
|
||||
|
||||
<ul class="space-y-1 border-l border-white/10 ml-[1px]">
|
||||
<li v-for="item in toc" :key="item.id">
|
||||
<a
|
||||
@click.prevent="scrollToHeading(item.id)"
|
||||
href="#"
|
||||
class="block text-xs py-1.5 transition-all duration-200 border-l-2 border-transparent pl-4 hover:text-white"
|
||||
:class="[
|
||||
activeId === item.id ? 'text-art-accent font-medium' : 'text-art-muted',
|
||||
item.level > 2 ? 'pl-8' : 'pl-4'
|
||||
]"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="toc.length === 0" class="text-xs text-art-muted italic pl-4">
|
||||
暂无目录
|
||||
<div v-else class="xl:flex xl:gap-12 relative">
|
||||
<!-- 主内容区域 -->
|
||||
<div class="flex-1 space-y-12">
|
||||
<!-- 文章信息 -->
|
||||
<div class="border-b border-white/5 pb-10">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<span class="px-3 py-1 border border-white/20 rounded-full text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.categoryName || '未分类' }}</span>
|
||||
<span class="text-sm text-art-muted">{{ post.date }}</span>
|
||||
</div>
|
||||
<h1 class="font-serif text-4xl md:text-5xl lg:text-6xl text-white leading-tight mb-8">{{ post.title }}</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden border border-white/20">
|
||||
<img
|
||||
src="https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao"
|
||||
alt="Avatar"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</div>
|
||||
</nav>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white">年糕崽崽</div>
|
||||
<div class="text-xs text-art-muted">前端架构师</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 文章信息 -->
|
||||
<div class="border-b border-white/5 pb-10">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<span class="px-3 py-1 border border-white/20 rounded-full text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.categoryName || '未分类' }}</span>
|
||||
<span class="text-sm text-art-muted">{{ post.date }}</span>
|
||||
</div>
|
||||
<h1 class="font-serif text-4xl md:text-5xl lg:text-6xl text-white leading-tight mb-8">{{ post.title }}</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden border border-white/20">
|
||||
<img
|
||||
src="https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao"
|
||||
alt="Avatar"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white">年糕崽崽</div>
|
||||
<div class="text-xs text-art-muted">前端架构师</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 文章内容 - 使用 v-html 渲染 Markdown 解析后的 HTML -->
|
||||
<!-- ref="articleRef" 用于后续绑定复制事件和提取目录 -->
|
||||
<article ref="articleRef" class="blog-content prose prose-invert prose-lg max-w-none text-art-muted leading-relaxed">
|
||||
<div v-html="renderedContent"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 文章内容 - 使用 v-html 渲染 Markdown 解析后的 HTML -->
|
||||
<!-- ref="articleRef" 用于后续绑定复制事件和提取目录 -->
|
||||
<article ref="articleRef" class="blog-content prose prose-invert prose-lg max-w-none text-art-muted leading-relaxed">
|
||||
<div v-html="renderedContent"></div>
|
||||
</article>
|
||||
<!-- 目录侧边栏 (仅在 XL 屏幕显示) -->
|
||||
<aside class="hidden xl:block w-64 flex-shrink-0">
|
||||
<div class="sticky top-32 space-y-6">
|
||||
<!-- 目录导航 -->
|
||||
<div class="p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="list" class="w-4 h-4 text-art-accent"></i>
|
||||
目录导航
|
||||
</h3>
|
||||
<nav class="space-y-1 relative">
|
||||
<!-- 激活状态的指示条 -->
|
||||
<div
|
||||
class="absolute left-0 w-0.5 bg-art-accent transition-all duration-300 ease-out"
|
||||
:style="activeIndicatorStyle"
|
||||
v-if="toc.length > 0"
|
||||
></div>
|
||||
|
||||
<ul class="space-y-1 border-l border-white/10 ml-[1px]">
|
||||
<li v-for="item in toc" :key="item.id">
|
||||
<a
|
||||
@click.prevent="scrollToHeading(item.id)"
|
||||
href="#"
|
||||
class="block text-xs py-1.5 transition-all duration-200 border-l-2 border-transparent pl-4 hover:text-white"
|
||||
:class="[
|
||||
activeId === item.id ? 'text-art-accent font-medium' : 'text-art-muted',
|
||||
item.level > 2 ? 'pl-8' : 'pl-4'
|
||||
]"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="toc.length === 0" class="text-xs text-art-muted italic pl-4">
|
||||
暂无目录
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 专栏跳转卡片 -->
|
||||
<div v-if="post.columnId && post.columnName" class="p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="book-open" class="w-4 h-4 text-art-accent"></i>
|
||||
所属专栏
|
||||
</h3>
|
||||
<a
|
||||
@click.prevent="router.push(`/columns/${post.columnId}`)"
|
||||
href="#"
|
||||
class="block p-4 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-200 group"
|
||||
>
|
||||
<div class="text-sm font-medium text-white group-hover:text-art-accent transition-colors mb-1">
|
||||
{{ post.columnName }}
|
||||
</div>
|
||||
<div class="text-xs text-art-muted flex items-center gap-1">
|
||||
<i data-lucide="arrow-right" class="w-3 h-3"></i>
|
||||
查看专栏
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 分类搜索卡片 -->
|
||||
<div v-if="post.categoryId && post.categoryName" class="p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="folder" class="w-4 h-4 text-art-accent"></i>
|
||||
分类
|
||||
</h3>
|
||||
<a
|
||||
@click.prevent="router.push(`/blog?category=${post.categoryId}`)"
|
||||
href="#"
|
||||
class="block p-4 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-200 group"
|
||||
>
|
||||
<div class="text-sm font-medium text-white group-hover:text-art-accent transition-colors mb-1">
|
||||
{{ post.categoryName }}
|
||||
</div>
|
||||
<div class="text-xs text-art-muted flex items-center gap-1">
|
||||
<i data-lucide="arrow-right" class="w-3 h-3"></i>
|
||||
查看分类
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 标签搜索卡片 -->
|
||||
<div v-if="post.tags && post.tags.length > 0" class="p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="tags" class="w-4 h-4 text-art-accent"></i>
|
||||
标签
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
v-for="tag in post.tags"
|
||||
:key="tag.id"
|
||||
@click.prevent="router.push(`/blog?tag=${tag.id}`)"
|
||||
href="#"
|
||||
class="px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 hover:bg-art-accent/20 hover:border-art-accent/50 hover:text-art-accent text-xs text-art-muted transition-all duration-200"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐博客 -->
|
||||
<div class="p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="sparkles" class="w-4 h-4 text-art-accent"></i>
|
||||
推荐阅读
|
||||
</h3>
|
||||
<div v-if="loadingRecommendations" class="flex justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
<div v-else-if="recommendedPosts.length === 0" class="text-xs text-art-muted italic py-4">
|
||||
暂无推荐
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<a
|
||||
v-for="recPost in recommendedPosts"
|
||||
:key="recPost.id"
|
||||
@click.prevent="router.push(`/blog/${recPost.id}`)"
|
||||
href="#"
|
||||
class="block p-4 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-200 group"
|
||||
>
|
||||
<div class="text-sm font-medium text-white group-hover:text-art-accent transition-colors mb-2 line-clamp-2">
|
||||
{{ recPost.title }}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-art-muted">
|
||||
<span v-if="recPost.categoryName" class="px-2 py-0.5 rounded border border-white/20">
|
||||
{{ recPost.categoryName }}
|
||||
</span>
|
||||
<span>{{ recPost.date }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -100,7 +200,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchPost, Post, getPublicSettings } from '../services/api'
|
||||
import { fetchPost, getRecommendedPosts, Post, getPublicSettings } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
|
||||
// 引入 Markdown 解析依赖
|
||||
@@ -138,6 +238,8 @@ const post = ref<Post>({
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const recommendedPosts = ref<Post[]>([])
|
||||
const loadingRecommendations = ref(false)
|
||||
|
||||
const { initObserver } = useScrollAnimation()
|
||||
|
||||
@@ -371,6 +473,9 @@ const fetchBlogDetail = async () => {
|
||||
await updatePageTitle(postData.title)
|
||||
}
|
||||
|
||||
// 获取推荐文章
|
||||
fetchRecommendations()
|
||||
|
||||
// 初始化动画、事件监听和目录 - 在数据加载完成后
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
@@ -392,6 +497,20 @@ const fetchBlogDetail = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取推荐文章
|
||||
const fetchRecommendations = async () => {
|
||||
loadingRecommendations.value = true
|
||||
try {
|
||||
const posts = await getRecommendedPosts(postId, 3)
|
||||
recommendedPosts.value = posts
|
||||
} catch (err) {
|
||||
console.error('Error fetching recommendations:', err)
|
||||
recommendedPosts.value = []
|
||||
} finally {
|
||||
loadingRecommendations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchBlogDetail()
|
||||
})
|
||||
|
||||
@@ -49,13 +49,14 @@
|
||||
@click="$router.push('/blog/' + featuredPost.id)"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent z-10"></div>
|
||||
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=2564&auto=format&fit=crop')] bg-cover bg-center transition-transform duration-700 group-hover:scale-105 opacity-60 mix-blend-overlay"></div>
|
||||
<div class="absolute inset-0 bg-[url('/public/bg/img.png')] bg-cover bg-center transition-transform duration-700 group-hover:scale-105 opacity-60 mix-blend-overlay"></div>
|
||||
<div class="relative z-20 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="px-2 py-1 text-[10px] font-mono border border-white/20 rounded-full text-white backdrop-blur-sm">{{ featuredPost.categoryName || '未分类' }}</span>
|
||||
<span class="text-xs text-white/60">{{ featuredPost.date }}</span>
|
||||
</div>
|
||||
<h3 class="font-serif text-3xl text-white group-hover:text-art-accent transition-colors">当极简主义遇见复杂数据:Dashboard 设计哲学</h3>
|
||||
<h3 class="font-serif text-3xl text-white group-hover:text-art-accent transition-colors">
|
||||
{{ featuredPost.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
BIN
client/src/public/bg/img.png
Normal file
BIN
client/src/public/bg/img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -560,6 +560,19 @@ export const fetchPost = async (id: number | string): Promise<Post> => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取推荐文章(基于IP的协同过滤)
|
||||
export const getRecommendedPosts = async (postId: number | string, limit: number = 3): Promise<Post[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/posts/${postId}/recommendations?limit=${limit}`)
|
||||
if (!response.ok) throw new Error('Failed to fetch recommended posts')
|
||||
const data = await response.json()
|
||||
return data.result || []
|
||||
} catch (error) {
|
||||
console.error(`Error fetching recommended posts for ${postId}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const createPost = async (postData: Omit<Post, 'id'>): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/posts`, {
|
||||
|
||||
@@ -368,3 +368,38 @@ func GetPostsByTagID(c *gin.Context) {
|
||||
utils.Success(c, res)
|
||||
}
|
||||
}
|
||||
|
||||
// GetRecommendedPosts 获取推荐文章(基于IP的协同过滤)
|
||||
func GetRecommendedPosts(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
var id uint
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
|
||||
utils.Error(c, 400, "Invalid post ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
userIP := c.ClientIP()
|
||||
|
||||
// 获取推荐文章(默认3篇)
|
||||
limit := 3
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 10 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
posts, err := repositories.GetRecommendedPostsByIP(id, userIP, limit)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
responses := repositories.BuildPostsResponse(posts)
|
||||
if responses == nil {
|
||||
utils.Success(c, []interface{}{})
|
||||
} else {
|
||||
utils.Success(c, responses)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func main() {
|
||||
// 博客路由
|
||||
api.GET("/posts", handlers.GetPosts)
|
||||
api.GET("/posts/:id", handlers.GetPost)
|
||||
api.GET("/posts/:id/recommendations", handlers.GetRecommendedPosts)
|
||||
|
||||
// 分类路由
|
||||
api.GET("/categories", handlers.GetCategories)
|
||||
|
||||
@@ -339,7 +339,8 @@ func SavePostHistory(post *models.Post, modifiedBy uint) error {
|
||||
func GetTopPosts(limit int) ([]models.Post, error) {
|
||||
var posts []models.Post
|
||||
err := config.DB.Model(&models.Post{}).
|
||||
Select("id, title, read_count").
|
||||
Preload("Category").
|
||||
Preload("Tags").
|
||||
Where("is_published = ? AND deleted_at = ?", 1, 0).
|
||||
Order("read_count DESC").
|
||||
Limit(limit).
|
||||
@@ -470,3 +471,95 @@ func BuildPostHistoryResponses(history []models.PostHistory) []models.PostHistor
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// GetRecommendedPostsByIP 基于IP的协同过滤推荐算法
|
||||
// 查找相同IP访问过的其他文章,按访问频率排序返回
|
||||
func GetRecommendedPostsByIP(currentPostID uint, userIP string, limit int) ([]models.Post, error) {
|
||||
if userIP == "" {
|
||||
// 如果IP为空,返回热门文章作为备选
|
||||
return GetTopPosts(limit)
|
||||
}
|
||||
|
||||
// 1. 查找相同IP访问过的其他文章ID及其访问次数
|
||||
type PostAccessCount struct {
|
||||
ArticleID uint
|
||||
Count int64
|
||||
}
|
||||
|
||||
var accessCounts []PostAccessCount
|
||||
err := config.DB.Model(&models.UserAccessLog{}).
|
||||
Select("article_id, COUNT(*) as count").
|
||||
Where("user_ip = ? AND article_id != ? AND deleted_at = ?", userIP, currentPostID, 0).
|
||||
Group("article_id").
|
||||
Order("count DESC").
|
||||
Limit(limit * 2). // 多查询一些,因为后面还要过滤未发布的文章
|
||||
Scan(&accessCounts).Error
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error querying recommended posts by IP: %v", err)
|
||||
// 如果查询失败,返回热门文章作为备选
|
||||
return GetTopPosts(limit)
|
||||
}
|
||||
|
||||
if len(accessCounts) == 0 {
|
||||
// 如果没有访问记录,返回热门文章作为备选
|
||||
return GetTopPosts(limit)
|
||||
}
|
||||
|
||||
// 2. 提取文章ID列表
|
||||
articleIDs := make([]uint, 0, len(accessCounts))
|
||||
for _, ac := range accessCounts {
|
||||
articleIDs = append(articleIDs, ac.ArticleID)
|
||||
}
|
||||
|
||||
// 3. 查询这些文章的详细信息(排除未发布的文章)
|
||||
var posts []models.Post
|
||||
err = config.DB.Model(&models.Post{}).
|
||||
Preload("Category").
|
||||
Preload("Tags").
|
||||
Where("id IN ? AND is_published = ? AND deleted_at = ?", articleIDs, 1, 0).
|
||||
Find(&posts).Error
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error fetching recommended posts: %v", err)
|
||||
return GetTopPosts(limit)
|
||||
}
|
||||
|
||||
// 4. 按照访问次数排序(保持与 accessCounts 的顺序一致)
|
||||
postMap := make(map[uint]models.Post)
|
||||
for _, post := range posts {
|
||||
postMap[post.ID] = post
|
||||
}
|
||||
|
||||
var sortedPosts []models.Post
|
||||
for _, ac := range accessCounts {
|
||||
if post, exists := postMap[ac.ArticleID]; exists {
|
||||
sortedPosts = append(sortedPosts, post)
|
||||
if len(sortedPosts) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 如果推荐的文章数量不足,用热门文章补充
|
||||
if len(sortedPosts) < limit {
|
||||
topPosts, err := GetTopPosts(limit - len(sortedPosts))
|
||||
if err == nil {
|
||||
// 排除已经推荐的文章
|
||||
existingIDs := make(map[uint]bool)
|
||||
for _, p := range sortedPosts {
|
||||
existingIDs[p.ID] = true
|
||||
}
|
||||
for _, p := range topPosts {
|
||||
if !existingIDs[p.ID] && p.ID != currentPostID {
|
||||
sortedPosts = append(sortedPosts, p)
|
||||
if len(sortedPosts) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortedPosts, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user