文章标签系统
实现文章的标签分类和标签云功能。
数据结构
定义类型
ts
// .vitepress/theme/types/tags.ts
export interface Tag {
name: string
count: number
slug: string
color?: string
icon?: string
}
export interface Article {
title: string
path: string
tags: string[]
date?: string
excerpt?: string
}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
构建时生成标签数据
创建脚本
ts
// scripts/generate-tags.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
interface Article {
title: string
path: string
tags: string[]
date: string
excerpt: string
}
interface TagData {
name: string
count: number
articles: string[]
}
const articles: Article[] = []
const tagMap = new Map<string, TagData>()
// 标签颜色映射
const tagColors: Record<string, string> = {
'VitePress': '#6366f1',
'Vue': '#42b883',
'TypeScript': '#3178c6',
'JavaScript': '#f7df1e',
'CSS': '#264de4',
'Markdown': '#083fa1',
'SEO': '#cf2e2e',
'性能优化': '#ff6b6b',
'部署': '#10b981',
'插件': '#8b5cf6'
}
function walk(dir: string) {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory() && !entry.name.startsWith('.')) {
walk(fullPath)
} else if (entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8')
const { data, content: body } = matter(content)
if (!data.tags || data.tags.length === 0) continue
const relativePath = fullPath
.replace('docs/', '')
.replace('.md', '')
const excerpt = body
.replace(/^#.*\n/, '')
.slice(0, 200)
.trim()
articles.push({
title: data.title || entry.name,
path: relativePath,
tags: data.tags,
date: data.date || data.updated || '',
excerpt
})
// 统计标签
for (const tag of data.tags) {
const existing = tagMap.get(tag)
if (existing) {
existing.count++
existing.articles.push(relativePath)
} else {
tagMap.set(tag, {
name: tag,
count: 1,
articles: [relativePath]
})
}
}
}
}
}
walk('docs')
// 生成标签数据
const tags = Array.from(tagMap.values())
.sort((a, b) => b.count - a.count)
.map(tag => ({
...tag,
color: tagColors[tag.name] || '#6366f1'
}))
// 生成标签索引数据
const tagIndex: Record<string, Article[]> = {}
for (const tag of tags) {
tagIndex[tag.name] = articles.filter(a => a.tags.includes(tag.name))
}
// 保存数据
fs.writeFileSync('docs/public/tags.json', JSON.stringify({
tags,
tagIndex
}, null, 2))
fs.writeFileSync('docs/public/articles.json', JSON.stringify(articles, null, 2))
console.log(`✅ 标签数据已生成`)
console.log(`📊 共 ${tags.length} 个标签, ${articles.length} 篇文章`)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
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
标签云组件
vue
<!-- .vitepress/theme/components/TagCloud.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
interface Tag {
name: string
count: number
color: string
}
const tags = ref<Tag[]>([])
const selectedTag = ref<string | null>(null)
onMounted(async () => {
const res = await fetch('/tags.json')
const data = await res.json()
tags.value = data.tags
})
// 根据数量计算字体大小
const getSize = (count: number) => {
const min = 12
const max = 24
const maxCount = Math.max(...tags.value.map(t => t.count))
return min + (count / maxCount) * (max - min)
}
const emit = defineEmits<{
select: [tag: string]
}>()
const handleSelect = (tag: string) => {
selectedTag.value = selectedTag.value === tag ? null : tag
emit('select', tag)
}
</script>
<template>
<div class="tag-cloud">
<a
v-for="tag in tags"
:key="tag.name"
class="tag"
:class="{ active: selectedTag === tag.name }"
:style="{
fontSize: `${getSize(tag.count)}px`,
'--tag-color': tag.color
}"
@click="handleSelect(tag.name)"
>
<span class="name">{{ tag.name }}</span>
<span class="count">{{ tag.count }}</span>
</a>
</div>
</template>
<style scoped>
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding: 1rem 0;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
background: var(--vp-c-bg-alt);
border-radius: 16px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
color: var(--vp-c-text-1);
}
.tag:hover,
.tag.active {
background: var(--tag-color);
color: white;
transform: scale(1.05);
}
.count {
font-size: 0.75em;
opacity: 0.7;
}
</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
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
标签文章列表
vue
<!-- .vitepress/theme/components/TagArticles.vue -->
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useRouter } from 'vitepress'
interface Article {
title: string
path: string
date: string
excerpt: string
}
const props = defineProps<{
tag: string
}>()
const articles = ref<Article[]>([])
const router = useRouter()
onMounted(async () => {
await loadArticles()
})
watch(() => props.tag, loadArticles)
async function loadArticles() {
if (!props.tag) return
const res = await fetch('/tags.json')
const data = await res.json()
const paths = data.tagIndex[props.tag] || []
const res2 = await fetch('/articles.json')
const allArticles = await res2.json()
articles.value = allArticles.filter(
(a: Article) => paths.includes(a.path)
)
}
function navigate(path: string) {
router.go(`/${path}`)
}
</script>
<template>
<div class="tag-articles">
<h3 class="tag-title">
标签:{{ tag }}
<span class="count">{{ articles.length }} 篇</span>
</h3>
<article
v-for="article in articles"
:key="article.path"
class="article-card"
@click="navigate(article.path)"
>
<h4 class="title">{{ article.title }}</h4>
<p class="excerpt">{{ article.excerpt }}</p>
<span class="date">{{ article.date }}</span>
</article>
</div>
</template>
<style scoped>
.tag-articles {
margin-top: 1.5rem;
}
.tag-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
margin-bottom: 1rem;
}
.count {
font-size: 0.875rem;
color: var(--vp-c-text-2);
font-weight: normal;
}
.article-card {
padding: 1rem;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.article-card:hover {
border-color: var(--vp-c-brand-1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.excerpt {
font-size: 0.875rem;
color: var(--vp-c-text-2);
margin-bottom: 0.5rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.date {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
</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
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
标签详情页布局
vue
<!-- .vitepress/theme/layouts/TagLayout.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vitepress'
import TagCloud from '../components/TagCloud.vue'
import TagArticles from '../components/TagArticles.vue'
const route = useRoute()
const currentTag = ref('')
onMounted(() => {
// 从 URL hash 获取标签
const hash = window.location.hash.slice(1)
if (hash) {
currentTag.value = decodeURIComponent(hash)
}
})
const handleTagSelect = (tag: string) => {
currentTag.value = tag
window.location.hash = tag
}
</script>
<template>
<div class="tag-layout">
<h1>标签分类</h1>
<TagCloud @select="handleTagSelect" />
<TagArticles v-if="currentTag" :tag="currentTag" />
<div v-else class="empty">
<p>选择一个标签查看相关文章</p>
</div>
</div>
</template>
<style scoped>
.tag-layout {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
h1 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
.empty {
text-align: center;
padding: 3rem;
color: var(--vp-c-text-2);
}
</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
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
在 frontmatter 中使用
yaml
---
title: 我的文章
tags:
- VitePress
- Vue
- 教程
---1
2
3
4
5
6
7
2
3
4
5
6
7