Skip to content

文章归档页面

创建按时间线展示文章的归档页面。

数据准备

生成归档数据

ts
// scripts/generate-archive.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'

interface ArchiveArticle {
  title: string
  path: string
  date: string
  year: number
  month: number
  tags: string[]
}

const articles: ArchiveArticle[] = []

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 } = matter(content)
      
      if (!data.date) continue
      
      const date = new Date(data.date)
      
      articles.push({
        title: data.title || entry.name,
        path: fullPath.replace('docs/', '').replace('.md', ''),
        date: data.date,
        year: date.getFullYear(),
        month: date.getMonth() + 1,
        tags: data.tags || []
      })
    }
  }
}

walk('docs')

// 按日期排序
articles.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())

// 按年分组
interface YearGroup {
  year: number
  count: number
  months: {
    month: number
    articles: ArchiveArticle[]
  }[]
}

const archive: YearGroup[] = []

let currentYear = 0
let currentYearGroup: YearGroup | null = null

for (const article of articles) {
  if (article.year !== currentYear) {
    currentYear = article.year
    currentYearGroup = {
      year: currentYear,
      count: 0,
      months: []
    }
    archive.push(currentYearGroup)
  }
  
  currentYearGroup!.count++
  
  let monthGroup = currentYearGroup!.months.find(m => m.month === article.month)
  if (!monthGroup) {
    monthGroup = { month: article.month, articles: [] }
    currentYearGroup!.months.push(monthGroup)
  }
  
  monthGroup.articles.push(article)
}

fs.writeFileSync('docs/public/archive.json', JSON.stringify({
  archive,
  total: articles.length
}, null, 2))

console.log(`✅ 归档数据已生成: ${articles.length} 篇文章`)

归档布局组件

vue
<!-- .vitepress/theme/layouts/ArchiveLayout.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vitepress'

interface Article {
  title: string
  path: string
  date: string
  tags: string[]
}

interface MonthGroup {
  month: number
  articles: Article[]
}

interface YearGroup {
  year: number
  count: number
  months: MonthGroup[]
}

const archive = ref<YearGroup[]>([])
const total = ref(0)
const expandedYears = ref<Set<number>>(new Set())
const searchQuery = ref('')
const router = useRouter()

onMounted(async () => {
  const res = await fetch('/archive.json')
  const data = await res.json()
  archive.value = data.archive
  total.value = data.total
  
  // 默认展开最近两年
  archive.value.slice(0, 2).forEach(g => expandedYears.value.add(g.year))
})

const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', 
                    '七月', '八月', '九月', '十月', '十一月', '十二月']

const toggleYear = (year: number) => {
  if (expandedYears.value.has(year)) {
    expandedYears.value.delete(year)
  } else {
    expandedYears.value.add(year)
  }
}

const filteredArchive = computed(() => {
  if (!searchQuery.value) return archive.value
  
  const query = searchQuery.value.toLowerCase()
  
  return archive.value.map(year => ({
    ...year,
    months: year.months.map(month => ({
      ...month,
      articles: month.articles.filter(a => 
        a.title.toLowerCase().includes(query) ||
        a.tags.some(t => t.toLowerCase().includes(query))
      )
    })).filter(m => m.articles.length > 0)
  })).filter(y => y.months.length > 0)
})

const navigate = (path: string) => {
  router.go(`/${path}`)
}
</script>

<template>
  <div class="archive-layout">
    <header class="archive-header">
      <h1>文章归档</h1>
      <p class="total">共 {{ total }} 篇文章</p>
    </header>
    
    <div class="search-box">
      <input
        v-model="searchQuery"
        type="text"
        placeholder="搜索文章..."
      />
    </div>
    
    <div class="timeline">
      <div
        v-for="yearGroup in filteredArchive"
        :key="yearGroup.year"
        class="year-group"
      >
        <div
          class="year-header"
          @click="toggleYear(yearGroup.year)"
        >
          <span class="year">{{ yearGroup.year }}</span>
          <span class="count">{{ yearGroup.count }} 篇</span>
          <span class="arrow" :class="{ expanded: expandedYears.has(yearGroup.year) }">

          </span>
        </div>
        
        <div v-show="expandedYears.has(yearGroup.year)" class="months">
          <div
            v-for="monthGroup in yearGroup.months"
            :key="monthGroup.month"
            class="month-group"
          >
            <div class="month-header">
              {{ monthNames[monthGroup.month - 1] }}
            </div>
            
            <article
              v-for="article in monthGroup.articles"
              :key="article.path"
              class="article-item"
              @click="navigate(article.path)"
            >
              <span class="date">{{ article.date.slice(5) }}</span>
              <span class="title">{{ article.title }}</span>
              <div class="tags">
                <span
                  v-for="tag in article.tags.slice(0, 2)"
                  :key="tag"
                  class="tag"
                >
                  {{ tag }}
                </span>
              </div>
            </article>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.archive-layout {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem 1.5rem;
}

.archive-header {
  text-align: center;
  margin-bottom: 2rem;
}

.archive-header h1 {
  font-size: 1.75rem;
  margin-bottom: 0.5rem;
}

.total {
  color: var(--vp-c-text-2);
}

.search-box {
  margin-bottom: 2rem;
}

.search-box input {
  width: 100%;
  padding: 0.75rem 1rem;
  border: 1px solid var(--vp-c-divider);
  border-radius: 8px;
  background: var(--vp-c-bg-alt);
  font-size: 1rem;
}

.search-box input:focus {
  outline: none;
  border-color: var(--vp-c-brand-1);
}

.year-group {
  margin-bottom: 1rem;
}

.year-header {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.75rem 1rem;
  background: var(--vp-c-bg-alt);
  border-radius: 8px;
  cursor: pointer;
  user-select: none;
}

.year-header:hover {
  background: var(--vp-c-bg);
}

.year {
  font-size: 1.25rem;
  font-weight: 600;
}

.count {
  color: var(--vp-c-text-2);
  font-size: 0.875rem;
}

.arrow {
  margin-left: auto;
  font-size: 0.75rem;
  color: var(--vp-c-text-3);
  transition: transform 0.2s;
}

.arrow.expanded {
  transform: rotate(180deg);
}

.months {
  padding-left: 1rem;
  border-left: 2px solid var(--vp-c-divider);
  margin: 0.5rem 0 0.5rem 1rem;
}

.month-header {
  padding: 0.5rem;
  color: var(--vp-c-text-2);
  font-size: 0.875rem;
  font-weight: 500;
}

.article-item {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.75rem;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.2s;
}

.article-item:hover {
  background: var(--vp-c-bg-alt);
}

.date {
  font-size: 0.85rem;
  color: var(--vp-c-text-3);
  font-variant-numeric: tabular-nums;
  min-width: 5ch;
}

.title {
  flex: 1;
  font-weight: 500;
}

.tags {
  display: flex;
  gap: 0.25rem;
}

.tag {
  font-size: 0.75rem;
  padding: 0.125rem 0.5rem;
  background: var(--vp-c-brand-soft);
  color: var(--vp-c-brand-1);
  border-radius: 4px;
}
</style>

简洁时间线样式

vue
<template>
  <div class="archive-layout">
    <div class="timeline-simple">
      <div
        v-for="yearGroup in archive"
        :key="yearGroup.year"
        class="year-section"
      >
        <h2 class="year-title">
          {{ yearGroup.year }}
          <span class="count">{{ yearGroup.count }}</span>
        </h2>
        
        <div class="articles-list">
          <a
            v-for="monthGroup in yearGroup.months"
            v-for="article in monthGroup.articles"
            :key="article.path"
            :href="`/${article.path}`"
            class="article-link"
          >
            <time class="date">{{ article.date }}</time>
            <span class="title">{{ article.title }}</span>
          </a>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.timeline-simple {
  position: relative;
}

.timeline-simple::before {
  content: '';
  position: absolute;
  left: 100px;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--vp-c-divider);
}

.year-section {
  margin-bottom: 2rem;
}

.year-title {
  position: relative;
  padding-left: 120px;
  font-size: 1.5rem;
  margin-bottom: 1rem;
}

.year-title .count {
  font-size: 0.875rem;
  color: var(--vp-c-text-3);
  margin-left: 0.5rem;
}

.articles-list {
  padding-left: 120px;
}

.article-link {
  position: relative;
  display: flex;
  gap: 1rem;
  padding: 0.5rem 0;
  text-decoration: none;
  color: var(--vp-c-text-1);
}

.article-link::before {
  content: '';
  position: absolute;
  left: -26px;
  top: 50%;
  width: 10px;
  height: 10px;
  background: var(--vp-c-bg);
  border: 2px solid var(--vp-c-brand-1);
  border-radius: 50%;
  transform: translateY(-50%);
}

.date {
  color: var(--vp-c-text-3);
  font-size: 0.85rem;
  min-width: 80px;
}
</style>

参考链接

贡献者

加载中...

想要成为贡献者?

在 CNB 上参与贡献