Skip to content

文章标签系统

实现文章的标签分类和标签云功能。

数据结构

定义类型

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
}

构建时生成标签数据

创建脚本

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} 篇文章`)

标签云组件

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>

标签文章列表

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>

标签详情页布局

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>

在 frontmatter 中使用

yaml
---
title: 我的文章
tags:
  - VitePress
  - Vue
  - 教程
---

参考链接

贡献者

加载中...

想要成为贡献者?

在 CNB 上参与贡献