主题定制概述
VitePress 提供了良好的默认主题,但在实际项目中,我们经常需要集成一些原生不支持的功能。通过深度定制,GABA Blog 实现了以下高级功能:
- 打字机效果:首页每日一言的动态显示
- 光标动画:自定义光标样式和动画
- 运行时间计数器:显示博客运行时间
- 双主题系统:light/dracula 主题切换
- 知识侧边栏:文章知识体系导航
Tailwind CSS 深度配置
tailwind.config.js 详细解析
javascript
module.exports = {
content: ['./src/**/*.{vue,js,ts,jsx,tsx,md}', './.vitepress/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
},
gridColumn: {
'span-13': 'span 13 / span 13',
'span-14': 'span 14 / span 14',
},
gridRow: {
'span-13': 'span 13 / span 13',
},
gridColumnStart: {
13: '13',
14: '14',
15: '15',
},
},
},
plugins: [require('@tailwindcss/typography'), require('daisyui')],
daisyui: {
themes: ['light', 'dracula'],
darkTheme: 'dracula',
base: true,
styled: true,
utils: true,
prefix: '',
logs: false,
themeRoot: ':root',
},
}关键配置说明
- 颜色系统扩展:自定义 primary 颜色调色板
- 字体配置:使用 Inter 字体作为默认 sans 字体
- 网格系统扩展:支持 13-15 列网格系统
- DaisyUI 集成:配置 light/dracula 双主题系统
打字机效果实现
HomeHero.vue 组件核心代码
vue
<template>
<div class="typewriter-container min-h-[120px] flex items-center justify-center">
<div class="typewriter-content text-center max-w-3xl mx-auto">
<div class="text-4xl md:text-5xl text-primary/30 mb-4">❝</div>
<div
class="typewriter-text text-xl md:text-2xl font-medium text-base-content leading-relaxed mb-4"
>
<span class="typed-text">{{ typedText }}</span>
<span class="cursor" :class="{ blinking: isTypingComplete }">|</span>
</div>
<div
v-if="quoteAuthor"
class="author-info text-base-content/70 text-sm md:text-base italic mt-6"
>
—— {{ quoteAuthor }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getDailyQuote } from '@utils/dailyQuote.mts'
const typedText = ref<string>('')
const isTypingComplete = ref<boolean>(false)
const quoteAuthor = ref<string>('')
const typingSpeed = 100
const pauseAfterTyping = 1000
let typingInterval: ReturnType<typeof setTimeout> | null = null
const startTypingAnimation = (quote: string, author: string) => {
typedText.value = ''
isTypingComplete.value = false
quoteAuthor.value = author
let charIndex = 0
if (typingInterval) {
clearTimeout(typingInterval)
typingInterval = null
}
const typeNextChar = () => {
if (charIndex < quote.length) {
typedText.value += quote.charAt(charIndex)
charIndex++
typingInterval = setTimeout(typeNextChar, typingSpeed)
} else {
typingInterval = null
setTimeout(() => {
isTypingComplete.value = true
}, pauseAfterTyping)
}
}
typingInterval = setTimeout(typeNextChar, typingSpeed)
}
onMounted(async () => {
const quote = await getDailyQuote()
const [text, author] = quote.split(' —— ')
startTypingAnimation(text, author || '')
})
</script>样式实现
css
<style scoped>
.typewriter-container {
position: relative;
}
.typewriter-content {
position: relative;
z-index: 1;
}
.typewriter-text {
position: relative;
min-height: 1.5em;
}
.typed-text {
display: inline-block;
white-space: pre-wrap;
word-break: break-word;
}
.cursor {
display: inline-block;
margin-left: 2px;
color: var(--primary);
font-weight: bold;
animation: cursor-blink 1s infinite;
}
.cursor.blinking {
animation: cursor-blink 0.7s infinite;
}
@keyframes cursor-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
</style>光标动画系统
cursor.mts 实现
typescript
export const cursor = () => {
const initCursor = () => {
const cursor = document.createElement('div')
cursor.id = 'custom-cursor'
cursor.style.cssText = `
position: fixed;
width: 20px;
height: 20px;
border: 2px solid var(--primary);
border-radius: 50%;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
transition: transform 0.1s ease;
mix-blend-mode: difference;
`
document.body.appendChild(cursor)
document.addEventListener('mousemove', (e) => {
cursor.style.left = `${e.clientX}px`
cursor.style.top = `${e.clientY}px`
})
document.addEventListener('mousedown', () => {
cursor.style.transform = 'translate(-50%, -50%) scale(0.8)'
})
document.addEventListener('mouseup', () => {
cursor.style.transform = 'translate(-50%, -50%) scale(1)'
})
const interactiveElements = document.querySelectorAll('a, button, [role="button"]')
interactiveElements.forEach((el) => {
el.addEventListener('mouseenter', () => {
cursor.style.transform = 'translate(-50%, -50%) scale(1.5)'
cursor.style.backgroundColor = 'var(--primary)'
})
el.addEventListener('mouseleave', () => {
cursor.style.transform = 'translate(-50%, -50%) scale(1)'
cursor.style.backgroundColor = 'transparent'
})
})
}
return { initCursor }
}在主题中启用
typescript
// .vitepress/theme/index.ts
import { cursor } from '@hooks/cursor.mts'
export default {
enhanceApp({ app }) {
app.mixin({
mounted() {
if (this.$root === this) {
const { initCursor } = cursor()
initCursor()
}
},
})
},
}运行时间计数器
duration.mts 实现
typescript
export const createRuntimeCounter = (
startDate: Date,
dateElementId: string,
timeElementId: string
) => {
const calculateRuntime = () => {
const now = new Date()
const diff = now.getTime() - startDate.getTime()
const years = Math.floor(diff / (1000 * 60 * 60 * 24 * 365))
const months = Math.floor((diff % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24 * 30))
const days = Math.floor((diff % (1000 * 60 * 60 * 24 * 30)) / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
const dateElement = document.getElementById(dateElementId)
if (dateElement) {
dateElement.textContent = `${years}年${months}月${days}天`
}
const timeElement = document.getElementById(timeElementId)
if (timeElement) {
timeElement.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
}
calculateRuntime()
setInterval(calculateRuntime, 1000)
}知识侧边栏功能
useKnowledgeSidebar.mts 实现
typescript
import { ref, computed, provide, inject } from 'vue'
export const KnowledgeSidebarKey = Symbol('knowledge-sidebar')
interface Article {
url: string
title: string
description?: string
tags?: string[]
categories?: string[]
date?: string
}
const articles = ref<Article[]>([])
export const setArticles = (data: Article[]) => {
articles.value = data
}
export const useKnowledgeSidebar = () => {
const activeCategory = ref<string>('')
const expandedCategories = ref<Set<string>>(new Set())
const categories = computed(() => {
const cats = new Map<string, Article[]>()
articles.value.forEach((post) => {
post.categories?.forEach((cat) => {
if (!cats.has(cat)) {
cats.set(cat, [])
}
cats.get(cat)!.push(post)
})
})
return cats
})
const toggleCategory = (category: string) => {
if (expandedCategories.value.has(category)) {
expandedCategories.value.delete(category)
} else {
expandedCategories.value.add(category)
}
}
return {
articles,
activeCategory,
categories,
expandedCategories,
toggleCategory,
setArticles,
}
}KnowledgeSidebar.vue 组件
vue
<template>
<aside class="knowledge-sidebar">
<div class="sidebar-header">
<h3>知识体系</h3>
</div>
<nav class="sidebar-nav">
<div v-for="[category, posts] in categories" :key="category" class="category-item">
<button class="category-title" @click="toggleCategory(category)">
{{ category }}
</button>
<ul v-if="expandedCategories.has(category)" class="category-posts">
<li v-for="post in posts" :key="post.url">
<a :href="post.url">{{ post.title }}</a>
</li>
</ul>
</div>
</nav>
</aside>
</template>
<script setup lang="ts">
import { useKnowledgeSidebar } from '@hooks/useKnowledgeSidebar.mts'
const { categories, expandedCategories, toggleCategory } = useKnowledgeSidebar()
</script>每日一言功能
dailyQuote.mts 实现
typescript
import quotes from '../../src/assets/json/quotes.json'
export const getDailyQuote = async (useOnline: boolean = true): Promise<string> => {
try {
if (useOnline) {
const response = await fetch('https://api.quotable.io/random')
if (response.ok) {
const data = await response.json()
return `${data.content} —— ${data.author}`
}
}
const today = new Date()
const dayOfYear = Math.floor(
(today.getTime() - new Date(today.getFullYear(), 0, 0).getTime()) / (1000 * 60 * 60 * 24)
)
const index = dayOfYear % quotes.length
return quotes[index]
} catch {
const randomIndex = Math.floor(Math.random() * quotes.length)
return quotes[randomIndex]
}
}工具函数
cn.mts - class 合并
typescript
import { type ClassValue, clsx } from 'clsx'
export const cn = (...inputs: ClassValue[]) => {
return clsx(inputs)
}contentComplexity.mts - 内容复杂度
typescript
export const calculateComplexity = (content: string): number => {
const words = content.split(/\s+/).length
const chars = content.length
const codeBlocks = (content.match(/```/g) || []).length
return Math.floor((words * 0.3 + chars * 0.5 + codeBlocks * 10) / 100)
}
export const getComplexityLabel = (complexity: number): string => {
if (complexity < 5) return '简单'
if (complexity < 10) return '中等'
return '复杂'
}总结
通过深度定制 VitePress 主题,GABA Blog 实现了许多原生不支持的高级功能:
- 视觉效果增强:打字机效果、光标动画提升了用户体验
- 交互功能丰富:运行时间计数器、每日一言增加了博客的趣味性
- 主题系统完善:双主题切换支持用户个性化偏好
- 导航功能增强:知识侧边栏帮助读者更好地浏览内容
- 布局系统强大:扩展的网格系统支持复杂布局设计
这些定制不仅提升了博客的功能性,也展示了 VitePress 主题系统的强大扩展能力。通过合理的架构设计和代码组织,我们可以轻松集成各种自定义功能,打造独特的博客体验。