多作者博客搭建
当博客从个人项目发展为团队协作时,需要支持多作者体系:每篇文章归属特定作者,每个作者有独立的主页,首页展示团队阵容。本文在 博客搭建实战 基础上,扩展多作者功能。
项目规划
目录结构
text
docs/
├── .vitepress/
│ ├── config.mts
│ └── theme/
│ ├── index.ts
│ ├── components/
│ │ ├── AuthorCard.vue # 作者卡片
│ │ ├── AuthorList.vue # 作者列表
│ │ ├── AuthorPage.vue # 作者主页
│ │ ├── ArticleMeta.vue # 文章元信息(含作者)
│ │ └── TeamSection.vue # 团队展示区
│ └── composables/
│ └── useAuthors.ts # 作者数据管理
├── authors/ # 作者主页
│ ├── zhang-san.md
│ ├── li-si.md
│ └── wang-wu.md
├── blog/ # 博客文章
│ ├── post-1.md
│ ├── post-2.md
│ └── post-3.md
└── index.md # 首页1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
数据模型
ts
// 类型定义
interface Author {
id: string
name: string
avatar: string
bio: string
social: {
github?: string
twitter?: string
website?: string
}
title?: string // 职位/头衔
location?: string // 所在地
joinedAt: string // 加入日期
}
interface PostWithAuthor {
url: string
title: string
excerpt: string
date: string
author: Author
tags: string[]
readingTime: number
}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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
作者数据管理
创建作者数据文件
ts
// docs/.vitepress/theme/composables/useAuthors.ts
import { useData } from 'vitepress'
// 作者信息集中管理
const authors: Record<string, Author> = {
'zhang-san': {
id: 'zhang-san',
name: '张三',
avatar: '/images/authors/zhang-san.jpg',
bio: '前端架构师,专注于 Vue 生态和工程化实践。热爱开源,长期贡献 VitePress 社区。',
title: '前端架构师',
location: '北京',
joinedAt: '2025-01-15',
social: {
github: 'https://github.com/zhang-san',
twitter: 'https://twitter.com/zhang-san',
website: 'https://zhang-san.dev'
}
},
'li-si': {
id: 'li-si',
name: '李四',
avatar: '/images/authors/li-si.jpg',
bio: '技术作家,专注开发者文档和知识管理。坚信好的文档能改变世界。',
title: '技术作家',
location: '上海',
joinedAt: '2025-03-01',
social: {
github: 'https://github.com/li-si',
website: 'https://li-si.me'
}
},
'wang-wu': {
id: 'wang-wu',
name: '王五',
avatar: '/images/authors/wang-wu.jpg',
bio: '全栈工程师,热衷于分享实战经验。从 Vue 2 时代一路走来的老兵。',
title: '全栈工程师',
location: '深圳',
joinedAt: '2025-06-10',
social: {
github: 'https://github.com/wang-wu'
}
}
}
export function useAuthors() {
function getAuthor(id: string): Author | undefined {
return authors[id]
}
function getAllAuthors(): Author[] {
return Object.values(authors)
}
function getAuthorByPost(authorId: string): Author {
return authors[authorId] || {
id: 'unknown',
name: '未知作者',
avatar: '/images/authors/default.jpg',
bio: '',
joinedAt: '',
social: {}
}
}
return {
getAuthor,
getAllAuthors,
getAuthorByPost
}
}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
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
文章 frontmatter 定义作者
yaml
---
title: VitePress 性能优化实战
date: 2026-04-20
author: zhang-san # 引用作者 ID
tags:
- VitePress
- 优化
---1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
数据加载器
扩展文章加载器
ts
// docs/blog.data.ts
import { createContentLoader } from 'vitepress'
import type { PostWithAuthor } from './.vitepress/theme/composables/useAuthors'
// 作者映射(构建时可用)
const authorMap: Record<string, { name: string; avatar: string }> = {
'zhang-san': { name: '张三', avatar: '/images/authors/zhang-san.jpg' },
'li-si': { name: '李四', avatar: '/images/authors/li-si.jpg' },
'wang-wu': { name: '王五', avatar: '/images/authors/wang-wu.jpg' }
}
declare const data: PostWithAuthor[]
export { data }
export default createContentLoader('blog/*.md', {
excerpt: ({ frontmatter }) => frontmatter.description || '',
transform(raw): PostWithAuthor[] {
return raw
.map(({ url, frontmatter, excerpt }) => {
const authorId = frontmatter.author || 'unknown'
const authorInfo = authorMap[authorId] || { name: '未知作者', avatar: '/images/authors/default.jpg' }
return {
url,
title: frontmatter.title || '',
excerpt: excerpt || '',
date: frontmatter.date || '',
author: {
id: authorId,
name: authorInfo.name,
avatar: authorInfo.avatar,
bio: '',
joinedAt: '',
social: {}
},
tags: frontmatter.tags || [],
readingTime: Math.ceil((frontmatter.wordCount || 500) / 200)
}
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}
})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
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
按作者过滤加载器
ts
// docs/blog-author.data.ts
import { createContentLoader } from 'vitepress'
interface AuthorPosts {
authorId: string
posts: {
url: string
title: string
date: string
excerpt: string
tags: string[]
}[]
}
declare const data: Record<string, AuthorPosts>
export { data }
export default createContentLoader('blog/*.md', {
transform(raw): Record<string, AuthorPosts> {
const result: Record<string, AuthorPosts> = {}
for (const { url, frontmatter, excerpt } of raw) {
const authorId = frontmatter.author || 'unknown'
if (!result[authorId]) {
result[authorId] = { authorId, posts: [] }
}
result[authorId].posts.push({
url,
title: frontmatter.title || '',
date: frontmatter.date || '',
excerpt: excerpt || '',
tags: frontmatter.tags || []
})
}
// 按日期排序
for (const key of Object.keys(result)) {
result[key].posts.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
)
}
return result
}
})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
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
组件开发
作者卡片组件
vue
<!-- .vitepress/theme/components/AuthorCard.vue -->
<script setup lang="ts">
interface Props {
author: {
id: string
name: string
avatar: string
bio: string
title?: string
location?: string
social?: {
github?: string
twitter?: string
website?: string
}
}
postCount?: number
compact?: boolean
}
withDefaults(defineProps<Props>(), {
postCount: 0,
compact: false
})
</script>
<template>
<div class="author-card" :class="{ compact }">
<a :href="`/authors/${author.id}`" class="author-link">
<img :src="author.avatar" :alt="author.name" class="author-avatar" />
</a>
<div class="author-info">
<a :href="`/authors/${author.id}`" class="author-name">{{ author.name }}</a>
<div v-if="author.title" class="author-title">{{ author.title }}</div>
<p v-if="!compact && author.bio" class="author-bio">{{ author.bio }}</p>
<div class="author-meta">
<span v-if="author.location" class="meta-item">📍 {{ author.location }}</span>
<span v-if="postCount > 0" class="meta-item">📝 {{ postCount }} 篇文章</span>
</div>
<div v-if="!compact && author.social" class="author-social">
<a
v-if="author.social.github"
:href="author.social.github"
target="_blank"
rel="noopener"
class="social-link"
>
GitHub
</a>
<a
v-if="author.social.twitter"
:href="author.social.twitter"
target="_blank"
rel="noopener"
class="social-link"
>
Twitter
</a>
<a
v-if="author.social.website"
:href="author.social.website"
target="_blank"
rel="noopener"
class="social-link"
>
个人网站
</a>
</div>
</div>
</div>
</template>
<style scoped>
.author-card {
display: flex;
gap: 16px;
padding: 24px;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
transition: border-color 0.25s, box-shadow 0.25s;
}
.author-card:hover {
border-color: var(--vp-c-brand);
box-shadow: 0 2px 12px rgba(var(--vp-c-brand-rgb), 0.1);
}
.author-card.compact {
padding: 12px;
gap: 12px;
}
.author-link {
flex-shrink: 0;
}
.author-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.compact .author-avatar {
width: 48px;
height: 48px;
}
.author-info {
flex: 1;
min-width: 0;
}
.author-name {
font-size: 18px;
font-weight: 600;
color: var(--vp-c-text-1);
text-decoration: none;
}
.author-name:hover {
color: var(--vp-c-brand);
}
.author-title {
font-size: 14px;
color: var(--vp-c-text-3);
margin-top: 2px;
}
.author-bio {
font-size: 14px;
color: var(--vp-c-text-2);
margin-top: 8px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.author-meta {
display: flex;
gap: 12px;
margin-top: 8px;
}
.meta-item {
font-size: 13px;
color: var(--vp-c-text-3);
}
.author-social {
display: flex;
gap: 12px;
margin-top: 12px;
}
.social-link {
font-size: 13px;
color: var(--vp-c-brand);
text-decoration: none;
padding: 2px 8px;
border: 1px solid var(--vp-c-brand);
border-radius: 4px;
transition: all 0.25s;
}
.social-link:hover {
background: var(--vp-c-brand);
color: var(--vp-c-white);
}
</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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
文章元信息组件
vue
<!-- .vitepress/theme/components/ArticleMeta.vue -->
<script setup lang="ts">
import { useData } from 'vitepress'
import { useAuthors } from '../composables/useAuthors'
import { computed } from 'vue'
const { frontmatter } = useData()
const { getAuthorByPost } = useAuthors()
const author = computed(() => getAuthorByPost(frontmatter.value.author || 'unknown'))
const formattedDate = computed(() => {
const date = frontmatter.value.date
if (!date) return ''
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
})
</script>
<template>
<div class="article-meta">
<a :href="`/authors/${author.id}`" class="meta-author">
<img :src="author.avatar" :alt="author.name" class="author-avatar" />
<div class="author-detail">
<span class="author-name">{{ author.name }}</span>
<span class="publish-date">{{ formattedDate }}</span>
</div>
</a>
<div v-if="frontmatter.tags?.length" class="meta-tags">
<a
v-for="tag in frontmatter.tags"
:key="tag"
:href="`/tags.html#${tag}`"
class="tag"
>
{{ tag }}
</a>
</div>
</div>
</template>
<style scoped>
.article-meta {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid var(--vp-c-divider);
margin-bottom: 24px;
}
.meta-author {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.author-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.author-detail {
display: flex;
flex-direction: column;
}
.author-name {
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.publish-date {
font-size: 12px;
color: var(--vp-c-text-3);
}
.meta-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-2);
text-decoration: none;
border: 1px solid var(--vp-c-divider);
transition: all 0.25s;
}
.tag:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
@media (max-width: 640px) {
.article-meta {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
</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
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
团队展示组件
vue
<!-- .vitepress/theme/components/TeamSection.vue -->
<script setup lang="ts">
import { useAuthors } from '../composables/useAuthors'
import AuthorCard from './AuthorCard.vue'
const { getAllAuthors } = useAuthors()
const authors = getAllAuthors()
</script>
<template>
<section class="team-section">
<h2 class="section-title">作者团队</h2>
<p class="section-desc">我们的团队成员来自不同领域,共同打造优质内容</p>
<div class="team-grid">
<AuthorCard
v-for="author in authors"
:key="author.id"
:author="author"
:post-count="0"
/>
</div>
</section>
</template>
<style scoped>
.team-section {
padding: 64px 24px;
max-width: 1152px;
margin: 0 auto;
}
.section-title {
font-size: 28px;
font-weight: 700;
text-align: center;
margin-bottom: 8px;
}
.section-desc {
text-align: center;
color: var(--vp-c-text-2);
margin-bottom: 48px;
}
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
@media (max-width: 768px) {
.team-grid {
grid-template-columns: 1fr;
}
}
</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
作者主页组件
vue
<!-- .vitepress/theme/components/AuthorPage.vue -->
<script setup lang="ts">
import { useAuthors } from '../composables/useAuthors'
import { data as authorPosts } from '../../blog-author.data'
import AuthorCard from './AuthorCard.vue'
const props = defineProps<{
authorId: string
}>()
const { getAuthor } = useAuthors()
const author = getAuthor(props.authorId)
const posts = authorPosts[props.authorId]?.posts || []
</script>
<template>
<div v-if="author" class="author-page">
<!-- 作者信息头部 -->
<div class="author-header">
<img :src="author.avatar" :alt="author.name" class="author-avatar" />
<div class="author-info">
<h1 class="author-name">{{ author.name }}</h1>
<div v-if="author.title" class="author-title">{{ author.title }}</div>
<p class="author-bio">{{ author.bio }}</p>
<div class="author-meta">
<span v-if="author.location">📍 {{ author.location }}</span>
<span>📝 {{ posts.length }} 篇文章</span>
<span v-if="author.joinedAt">🕐 加入于 {{ author.joinedAt }}</span>
</div>
<div class="author-social">
<a v-if="author.social?.github" :href="author.social.github" target="_blank" rel="noopener">
GitHub
</a>
<a v-if="author.social?.twitter" :href="author.social.twitter" target="_blank" rel="noopener">
Twitter
</a>
<a v-if="author.social?.website" :href="author.social.website" target="_blank" rel="noopener">
个人网站
</a>
</div>
</div>
</div>
<!-- 文章列表 -->
<div class="author-posts">
<h2 class="posts-title">文章列表</h2>
<div v-if="posts.length" class="post-list">
<a v-for="post in posts" :key="post.url" :href="post.url" class="post-item">
<span class="post-date">{{ post.date }}</span>
<span class="post-title">{{ post.title }}</span>
<div v-if="post.tags?.length" class="post-tags">
<span v-for="tag in post.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</a>
</div>
<p v-else class="no-posts">该作者暂无文章</p>
</div>
</div>
<div v-else class="not-found">
<h1>作者不存在</h1>
<a href="/blog/">返回博客首页</a>
</div>
</template>
<style scoped>
.author-header {
display: flex;
gap: 32px;
padding: 48px 0;
border-bottom: 1px solid var(--vp-c-divider);
margin-bottom: 32px;
}
.author-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.author-info {
flex: 1;
}
.author-name {
font-size: 32px;
font-weight: 700;
margin-bottom: 4px;
}
.author-title {
font-size: 16px;
color: var(--vp-c-text-3);
margin-bottom: 12px;
}
.author-bio {
font-size: 16px;
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 12px;
}
.author-meta {
display: flex;
gap: 16px;
font-size: 14px;
color: var(--vp-c-text-3);
margin-bottom: 12px;
}
.author-social {
display: flex;
gap: 12px;
}
.author-social a {
font-size: 13px;
color: var(--vp-c-brand);
text-decoration: none;
padding: 4px 12px;
border: 1px solid var(--vp-c-brand);
border-radius: 6px;
transition: all 0.25s;
}
.author-social a:hover {
background: var(--vp-c-brand);
color: var(--vp-c-white);
}
.posts-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.post-item {
display: block;
padding: 16px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
margin-bottom: 12px;
text-decoration: none;
transition: all 0.25s;
}
.post-item:hover {
border-color: var(--vp-c-brand);
transform: translateX(4px);
}
.post-date {
font-size: 13px;
color: var(--vp-c-text-3);
}
.post-title {
display: block;
font-size: 16px;
font-weight: 500;
color: var(--vp-c-text-1);
margin-top: 4px;
}
.post-tags {
display: flex;
gap: 6px;
margin-top: 8px;
}
.tag {
font-size: 12px;
padding: 2px 6px;
border-radius: 3px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-2);
border: 1px solid var(--vp-c-divider);
}
.no-posts {
color: var(--vp-c-text-3);
text-align: center;
padding: 32px;
}
.not-found {
text-align: center;
padding: 64px 24px;
}
@media (max-width: 640px) {
.author-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.author-meta {
justify-content: center;
flex-wrap: wrap;
}
.author-social {
justify-content: center;
}
}
</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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
作者主页页面
为每个作者创建独立的 Markdown 页面:
markdown
<!-- docs/authors/zhang-san.md -->
---
title: 张三
layout: page
---
<AuthorPage author-id="zhang-san" />1
2
3
4
5
6
7
2
3
4
5
6
7
markdown
<!-- docs/authors/li-si.md -->
---
title: 李四
layout: page
---
<AuthorPage author-id="li-si" />1
2
3
4
5
6
7
2
3
4
5
6
7
注册组件
ts
// .vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import AuthorCard from './components/AuthorCard.vue'
import AuthorPage from './components/AuthorPage.vue'
import ArticleMeta from './components/ArticleMeta.vue'
import TeamSection from './components/TeamSection.vue'
export default {
extends: DefaultTheme,
enhanceApp({ app }) {
app.component('AuthorCard', AuthorCard)
app.component('AuthorPage', AuthorPage)
app.component('ArticleMeta', ArticleMeta)
app.component('TeamSection', TeamSection)
}
}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
在文章中使用
文章模板自动显示作者
vue
<!-- .vitepress/theme/Layout.vue -->
<script setup lang="ts">
import DefaultTheme from 'vitepress/theme'
import ArticleMeta from './components/ArticleMeta.vue'
import { useData } from 'vitepress'
import { computed } from 'vue'
const { Layout } = DefaultTheme
const { frontmatter } = useData()
const isBlogPost = computed(() => frontmatter.value.layout === 'blog')
</script>
<template>
<Layout>
<template #doc-before>
<ArticleMeta v-if="isBlogPost" />
</template>
</Layout>
</template>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
文章 frontmatter
yaml
---
title: 使用 Vue 3 组合式 API 构建复杂组件
date: 2026-04-15
author: wang-wu
layout: blog
tags:
- Vue
- 组件
description: 深入探讨 Vue 3 组合式 API 在复杂场景下的应用模式
---1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
首页集成团队展示
markdown
<!-- docs/index.md -->
---
layout: home
---
<div class="home-team-section">
<TeamSection />
</div>1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
或者通过布局插槽插入:
vue
<!-- .vitepress/theme/Layout.vue -->
<script setup lang="ts">
import DefaultTheme from 'vitepress/theme'
import TeamSection from './components/TeamSection.vue'
const { Layout } = DefaultTheme
</script>
<template>
<Layout>
<template #home-features-after>
<TeamSection />
</template>
</Layout>
</template>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
侧边栏配置
ts
// .vitepress/config.mts
export default defineConfig({
themeConfig: {
sidebar: {
'/blog/': [
{
text: '博客',
items: [
{ text: '所有文章', link: '/blog/' }
]
},
{
text: '作者',
items: [
{ text: '张三', link: '/authors/zhang-san' },
{ text: '李四', link: '/authors/li-si' },
{ text: '王五', link: '/authors/wang-wu' }
]
}
]
}
}
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
高级功能
作者贡献统计
ts
// composables/useAuthorStats.ts
import { useAuthors } from './useAuthors'
import { data as authorPosts } from '../../blog-author.data'
export function useAuthorStats() {
const { getAllAuthors } = useAuthors()
function getAuthorPostCount(authorId: string): number {
return authorPosts[authorId]?.posts.length || 0
}
function getAuthorRanking() {
return getAllAuthors()
.map(author => ({
...author,
postCount: getAuthorPostCount(author.id)
}))
.sort((a, b) => b.postCount - a.postCount)
}
function getTotalStats() {
const authors = getAllAuthors()
const totalPosts = Object.values(authorPosts)
.reduce((sum, author) => sum + author.posts.length, 0)
return {
totalAuthors: authors.length,
totalPosts
}
}
return {
getAuthorPostCount,
getAuthorRanking,
getTotalStats
}
}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
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
RSS 订阅按作者
ts
// scripts/generate-author-rss.mjs
import { writeFileSync } from 'fs'
import { createContentLoader } from 'vitepress'
async function generateAuthorRss(authorId: string, authorName: string) {
const posts = await createContentLoader('blog/*.md', {
render: true,
excerpt: true
}).load()
const authorPosts = posts.filter(p => p.frontmatter.author === authorId)
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${authorName} 的博客</title>
<link>https://example.com/authors/${authorId}</link>
<description>${authorName} 发布的文章</description>
${authorPosts.map(post => `
<item>
<title>${post.frontmatter.title}</title>
<link>https://example.com${post.url}</link>
<description>${post.excerpt || ''}</description>
<pubDate>${new Date(post.frontmatter.date).toUTCString()}</pubDate>
</item>`).join('')}
</channel>
</rss>`
writeFileSync(`docs/public/feed/${authorId}.xml`, rss)
}
// 为每个作者生成 RSS
generateAuthorRss('zhang-san', '张三')
generateAuthorRss('li-si', '李四')
generateAuthorRss('wang-wu', '王五')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
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
最佳实践
| 实践 | 说明 |
|---|---|
| 作者 ID 稳定 | 作者 ID 一旦确定不要更改,会影响所有文章关联 |
| 头像统一尺寸 | 建议使用 200×200 像素的正方形图片 |
| Bio 控制长度 | 建议在 100 字以内,避免卡片过长 |
| 数据加载优化 | 作者数据使用单独的 data loader,避免重复加载 |
| 社交链接验证 | 定期检查作者社交链接是否有效 |
| 默认头像 | 为未设置头像的作者提供默认占位图 |
常见问题
Q: 如何动态添加新作者?
在 useAuthors.ts 中添加新作者信息,然后在 blog-author.data.ts 的 authorMap 中同步添加,最后创建对应的 authors/xxx.md 页面。
Q: 一篇文章可以有多个作者吗?
可以。将 author 字段改为 authors 数组:
yaml
---
authors:
- zhang-san
- li-si
---1
2
3
4
5
2
3
4
5
然后修改组件以支持多作者遍历显示。
Q: 如何限制作者只能编辑自己的文章?
这需要配合 Git 权限管理或 CI/CD 检查。可在 GitHub Actions 中添加路径权限检查:
yaml
# .github/workflows/check-author.yml
name: Check Author
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check author permission
run: |
# 检查 PR 修改的文件是否属于该作者
node scripts/check-author-permission.mjs1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12