相关文章推荐
基于标签匹配推荐相关文章,提升用户阅读体验。
推荐算法
基于标签匹配
ts
// .vitepress/theme/utils/related.ts
interface Article {
title: string
path: string
tags: string[]
date?: string
excerpt?: string
}
export function calculateSimilarity(tagsA: string[], tagsB: string[]): number {
if (!tagsA.length || !tagsB.length) return 0
const setA = new Set(tagsA)
const setB = new Set(tagsB)
// Jaccard 相似度
const intersection = new Set([...setA].filter(x => setB.has(x)))
const union = new Set([...setA, ...setB])
return intersection.size / union.size
}
export function findRelated(
current: Article,
allArticles: Article[],
limit: number = 4
): Article[] {
return allArticles
.filter(a => a.path !== current.path) // 排除当前文章
.map(a => ({
article: a,
score: calculateSimilarity(current.tags, a.tags)
}))
.filter(item => item.score > 0) // 只保留有共同标签的
.sort((a, b) => b.score - a.score) // 按相似度排序
.slice(0, limit)
.map(item => item.article)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
组件实现
vue
<!-- .vitepress/theme/components/RelatedPosts.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useData, useRouter } from 'vitepress'
import { findRelated } from '../utils/related'
interface Article {
title: string
path: string
tags: string[]
date?: string
excerpt?: string
}
const { page, frontmatter } = useData()
const router = useRouter()
const allArticles = ref<Article[]>([])
const relatedPosts = ref<Article[]>([])
onMounted(async () => {
const res = await fetch('/articles.json')
allArticles.value = await res.json()
const current: Article = {
title: page.value.title,
path: page.value.relativePath.replace('.md', ''),
tags: frontmatter.value.tags || []
}
relatedPosts.value = findRelated(current, allArticles.value)
})
const navigate = (path: string) => {
router.go(`/${path}`)
}
</script>
<template>
<div v-if="relatedPosts.length > 0" class="related-posts">
<h3 class="title">相关文章</h3>
<div class="posts-grid">
<article
v-for="post in relatedPosts"
:key="post.path"
class="post-card"
@click="navigate(post.path)"
>
<div class="post-content">
<h4 class="post-title">{{ post.title }}</h4>
<p v-if="post.excerpt" class="post-excerpt">{{ post.excerpt }}</p>
<div class="post-meta">
<span v-if="post.date" class="date">{{ post.date }}</span>
<div class="tags">
<span
v-for="tag in post.tags.slice(0, 3)"
:key="tag"
class="tag"
>
{{ tag }}
</span>
</div>
</div>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.related-posts {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--vp-c-divider);
}
.title {
font-size: 1.125rem;
margin-bottom: 1.5rem;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.post-card {
padding: 1rem;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.post-card:hover {
border-color: var(--vp-c-brand-1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.post-title {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.post-excerpt {
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-bottom: 0.75rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
}
.date {
color: var(--vp-c-text-3);
}
.tags {
display: flex;
gap: 0.25rem;
}
.tag {
padding: 0.125rem 0.375rem;
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-1);
border-radius: 3px;
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
高级:带相似度权重
vue
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
interface RelatedResult {
article: Article
score: number
commonTags: string[]
}
export function findRelatedWithDetails(
current: Article,
allArticles: Article[],
limit: number = 4
): RelatedResult[] {
const currentTags = new Set(current.tags)
return allArticles
.filter(a => a.path !== current.path)
.map(a => {
const articleTags = new Set(a.tags)
const commonTags = [...currentTags].filter(t => articleTags.has(t))
const score = commonTags.length
return { article: a, score, commonTags }
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
}
const relatedResults = ref<RelatedResult[]>([])
// 使用
const getTagWeight = (score: number) => {
if (score >= 3) return 'highly-related'
if (score >= 2) return 'related'
return 'somewhat-related'
}
</script>
<template>
<article
v-for="result in relatedResults"
:key="result.article.path"
:class="getTagWeight(result.score)"
>
<h4>{{ result.article.title }}</h4>
<div class="common-tags">
共同标签:
<span v-for="tag in result.commonTags" :key="tag">{{ tag }}</span>
</div>
</article>
</template>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
构建时预计算
ts
// scripts/generate-related.ts
import fs from 'fs'
import matter from 'gray-matter'
const articles: any[] = []
const relatedMap: Record<string, string[]> = {}
// ... 读取文章 ...
// 预计算每篇文章的相关文章
for (const article of articles) {
const related = findRelated(article, articles, 5)
relatedMap[article.path] = related.map(r => r.path)
}
fs.writeFileSync('docs/public/related.json', JSON.stringify(relatedMap, null, 2))1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
使用预计算数据
vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useData, useRouter } from 'vitepress'
const { page } = useData()
const router = useRouter()
const relatedPaths = ref<string[]>([])
const relatedArticles = ref<any[]>([])
onMounted(async () => {
const res1 = await fetch('/related.json')
const relatedMap = await res1.json()
const currentPath = page.value.relativePath.replace('.md', '')
relatedPaths.value = relatedMap[currentPath] || []
if (relatedPaths.value.length > 0) {
const res2 = await fetch('/articles.json')
const allArticles = await res2.json()
relatedArticles.value = relatedPaths.value
.map(path => allArticles.find((a: any) => a.path === path))
.filter(Boolean)
}
})
</script>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26