文章归档页面
创建按时间线展示文章的归档页面。
数据准备
生成归档数据
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>