协同编辑实战案例
当文档站从个人项目发展为团队协作时,需要一套完整的协同编辑机制:多人同时编写内容、自动化质量检查、内容审核流程和版本化发布。本文将搭建一套基于 Git 工作流 + CI/CD 的协作体系。
架构概览
mermaid
graph LR
A[作者编写] --> B[创建分支]
B --> C[提交 PR]
C --> D[CI 自动检查]
D -->|通过| E[内容审核]
D -->|失败| A
E -->|批准| F[合并到 main]
F --> G[自动构建部署]
E -->|需修改| A项目结构
text
docs/
├── .vitepress/
│ ├── config.mts
│ └── theme/
│ ├── index.ts
│ ├── components/
│ │ ├── ContributorList.vue # 贡献者列表
│ │ ├── ReviewStatus.vue # 审核状态标签
│ │ ├── EditLink.vue # 编辑链接
│ │ └── LastEditor.vue # 最后编辑者
│ └── composables/
│ └── useContributors.ts # 贡献者数据
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # CI 检查
│ │ ├── deploy.yml # 部署
│ │ └── preview.yml # PR 预览
│ ├── CODEOWNERS # 代码所有者
│ └── PULL_REQUEST_TEMPLATE.md # PR 模板
├── scripts/
│ ├── check-links.mts # 死链检查
│ ├── check-spell.mts # 拼写检查
│ ├── check-frontmatter.mts # Frontmatter 校验
│ └── generate-contributors.mts # 生成贡献者列表
├── content/ # 文档内容
│ ├── guide/
│ ├── api/
│ └── tutorial/
└── CONTRIBUTING.md # 贡献指南Git 协作工作流
分支策略
text
main ──────────────────────────────────► 生产分支
│
├── feat/add-i18n-guide ─────────────► 功能分支
│ └── PR → main
│
├── fix/fix-broken-link ─────────────► 修复分支
│ └── PR → main
│
└── docs/update-api-reference ───────► 文档分支
└── PR → main分支命名规范
| 类型 | 前缀 | 示例 |
|---|---|---|
| 新文档 | docs/ | docs/add-deployment-guide |
| 内容更新 | update/ | update/api-reference-v2 |
| 修复 | fix/ | fix/broken-links |
| 功能 | feat/ | feat/add-search |
| 翻译 | i18n/ | i18n/ja-guide |
Commit 规范
bash
# 文档新增
git commit -m "docs(guide): 添加部署指南"
# 内容更新
git commit -m "update(api): 更新 v2 API 参考"
# 修复错误
git commit -m "fix(links): 修复侧边栏死链"
# 翻译
git commit -m "i18n(ja): 翻译快速开始指南"PR 模板与审核
PR 模板
markdown
<!-- .github/PULL_REQUEST_TEMPLATE.md -->
## 变更类型
- [ ] 新增文档
- [ ] 更新内容
- [ ] 修复错误
- [ ] 翻译
- [ ] 其他
## 变更描述
<!-- 描述你的变更内容 -->
## 相关 Issue
<!-- 关联的 Issue 编号,如 Closes #123 -->
## 检查清单
- [ ] 已添加 frontmatter(title、description、tags)
- [ ] 代码块已指定语言类型
- [ ] 图片有 alt 文本
- [ ] 内部链接有效
- [ ] 已在本地预览确认CODEOWNERS 配置
text
# .github/CODEOWNERS
# 不同目录的审核人
/docs/guide/ @team-lead @senior-writer
/docs/api/ @api-team
/docs/tutorial/ @education-team
/docs/.vitepress/ @dev-team
/scripts/ @dev-team审核流程组件
vue
<!-- docs/.vitepress/theme/components/ReviewStatus.vue -->
<script setup lang="ts">
interface Props {
status: 'draft' | 'in-review' | 'approved' | 'published'
reviewers?: string[]
}
const props = withDefaults(defineProps<Props>(), {
reviewers: () => []
})
const statusConfig = {
draft: { label: '草稿', color: 'var(--vp-c-gray-1)', bg: 'var(--vp-c-gray-soft)' },
'in-review': { label: '审核中', color: 'var(--vp-c-warning-1)', bg: 'var(--vp-c-warning-soft)' },
approved: { label: '已批准', color: 'var(--vp-c-brand-1)', bg: 'var(--vp-c-brand-soft)' },
published: { label: '已发布', color: 'var(--vp-c-tip-1)', bg: 'var(--vp-c-tip-soft)' }
}
</script>
<template>
<div class="review-status">
<span
class="review-status__badge"
:style="{ color: statusConfig[status].color, backgroundColor: statusConfig[status].bg }"
>
{{ statusConfig[status].label }}
</span>
<span v-if="reviewers.length" class="review-status__reviewers">
审核人:{{ reviewers.join('、') }}
</span>
</div>
</template>
<style scoped>
.review-status {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0;
}
.review-status__badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
}
.review-status__reviewers {
font-size: 13px;
color: var(--vp-c-text-2);
}
</style>CI 自动化检查
完整 CI 配置
yaml
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
paths:
- 'docs/**'
- 'scripts/**'
jobs:
# 1. 构建检查
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
# 2. Frontmatter 校验
frontmatter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx tsx scripts/check-frontmatter.mts
# 3. 死链检查
links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- run: npx tsx scripts/check-links.mts
# 4. 拼写检查
spellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx tsx scripts/check-spell.mtsFrontmatter 校验脚本
typescript
// scripts/check-frontmatter.mts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
interface FrontmatterRule {
field: string
required: boolean
type?: string
validator?: (value: unknown) => string | null
}
const rules: FrontmatterRule[] = [
{ field: 'title', required: true, type: 'string' },
{ field: 'description', required: true, type: 'string' },
{
field: 'description',
required: false,
validator: (value) => {
if (typeof value === 'string' && value.length > 160) {
return `description 过长(${value.length} 字符),建议不超过 160 字符`
}
return null
}
},
{ field: 'tags', required: true, type: 'object' },
{ field: 'date', required: true, type: 'string' }
]
function checkFrontmatter(dir: string): number {
let errorCount = 0
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
errorCount += checkFrontmatter(fullPath)
} else if (entry.name.endsWith('.md') && entry.name !== 'index.md') {
const content = fs.readFileSync(fullPath, 'utf-8')
const { data } = matter(content)
const relativePath = path.relative(process.cwd(), fullPath)
for (const rule of rules) {
if (rule.required && !data[rule.field]) {
console.error(`❌ ${relativePath}: 缺少必填字段 "${rule.field}"`)
errorCount++
}
if (data[rule.field] && rule.type && typeof data[rule.field] !== rule.type) {
console.error(`❌ ${relativePath}: "${rule.field}" 类型应为 ${rule.type}`)
errorCount++
}
if (data[rule.field] && rule.validator) {
const msg = rule.validator(data[rule.field])
if (msg) {
console.warn(`⚠️ ${relativePath}: ${msg}`)
}
}
}
}
}
return errorCount
}
const errors = checkFrontmatter('docs')
if (errors > 0) {
console.error(`\n❌ 发现 ${errors} 个 frontmatter 错误`)
process.exit(1)
} else {
console.log('✅ Frontmatter 校验通过')
}死链检查脚本
typescript
// scripts/check-links.mts
import fs from 'fs'
import path from 'path'
interface LinkCheckResult {
file: string
link: string
type: 'internal' | 'external'
status: 'ok' | 'broken'
}
function extractLinks(content: string): string[] {
// 匹配 Markdown 链接 [text](url)
const mdLinkRegex = /\[([^\]]*)\]\(([^)]+)\)/g
// 匹配图片链接 
const imgLinkRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
const links: string[] = []
let match: RegExpExecArray | null
while ((match = mdLinkRegex.exec(content)) !== null) {
const url = match[2]
if (!url.startsWith('http') && !url.startsWith('#') && !url.startsWith('mailto:')) {
links.push(url)
}
}
while ((match = imgLinkRegex.exec(content)) !== null) {
const url = match[2]
if (!url.startsWith('http')) {
links.push(url)
}
}
return links
}
function checkInternalLinks(docsDir: string): LinkCheckResult[] {
const results: LinkCheckResult[] = []
const distDir = path.join(docsDir, '.vitepress', 'dist')
if (!fs.existsSync(distDir)) {
console.error('❌ 请先运行 npm run build 生成 dist 目录')
process.exit(1)
}
function scanDir(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 !== '.vitepress' && entry.name !== 'node_modules') {
scanDir(fullPath)
} else if (entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8')
const links = extractLinks(content)
const fileDir = path.dirname(fullPath)
for (const link of links) {
// 移除锚点
const linkWithoutAnchor = link.split('#')[0]
if (!linkWithoutAnchor) continue
// 解析相对路径
const resolvedPath = path.resolve(fileDir, linkWithoutAnchor)
// 检查 .md 文件或目录下的 index.md
const mdPath = resolvedPath.endsWith('.md')
? resolvedPath
: `${resolvedPath}.md`
const indexPath = resolvedPath.endsWith('/')
? path.join(resolvedPath, 'index.md')
: path.join(resolvedPath, 'index.md')
const exists = fs.existsSync(mdPath) || fs.existsSync(indexPath)
results.push({
file: path.relative(docsDir, fullPath),
link,
type: 'internal',
status: exists ? 'ok' : 'broken'
})
}
}
}
}
scanDir(docsDir)
return results
}
const results = checkInternalLinks('docs')
const broken = results.filter(r => r.status === 'broken')
if (broken.length > 0) {
console.error('\n❌ 发现以下死链:\n')
for (const item of broken) {
console.error(` ${item.file} → ${item.link}`)
}
console.error(`\n共 ${broken.length} 个死链`)
process.exit(1)
} else {
console.log('✅ 所有内部链接有效')
}PR 预览部署
每次 PR 提交后自动生成预览站点,方便审核者查看效果。
yaml
# .github/workflows/preview.yml
name: PR Preview
on:
pull_request:
branches: [main]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
# 部署预览到 Vercel
- name: Deploy Preview
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
working-directory: docs
# 在 PR 上评论预览链接
- name: Comment Preview URL
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '📖 预览链接已生成,请查看部署结果。'
})贡献者管理
自动生成贡献者列表
typescript
// scripts/generate-contributors.mts
import fs from 'fs'
import { execSync } from 'child_process'
interface Contributor {
name: string
email: string
commits: number
avatar?: string
}
function getContributors(): Contributor[] {
const log = execSync(
'git log --format="%aN|%aE" --no-merges docs/',
{ encoding: 'utf-8' }
)
const counts = new Map<string, Contributor>()
for (const line of log.trim().split('\n')) {
if (!line) continue
const [name, email] = line.split('|')
const key = email.toLowerCase()
if (counts.has(key)) {
counts.get(key)!.commits++
} else {
counts.set(key, {
name,
email,
commits: 1,
avatar: `https://gravatar.com/avatar/${email}?d=identicon`
})
}
}
return Array.from(counts.values()).sort((a, b) => b.commits - a.commits)
}
const contributors = getContributors()
fs.writeFileSync(
'docs/.vitepress/contributors.json',
JSON.stringify(contributors, null, 2),
'utf-8'
)
console.log(`✅ 生成 ${contributors.length} 位贡献者信息`)贡献者列表组件
vue
<!-- docs/.vitepress/theme/components/ContributorList.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface Contributor {
name: string
commits: number
avatar?: string
}
const contributors = ref<Contributor[]>([])
onMounted(async () => {
try {
const res = await fetch('/contributors.json')
contributors.value = await res.json()
} catch {
// 数据加载失败时静默处理
}
})
</script>
<template>
<div class="contributor-list">
<a
v-for="c in contributors"
:key="c.name"
class="contributor-item"
:title="`${c.name} (${c.commits} 次提交)`"
>
<img
v-if="c.avatar"
:src="c.avatar"
:alt="c.name"
class="contributor-avatar"
loading="lazy"
/>
<span class="contributor-name">{{ c.name }}</span>
<span class="contributor-count">{{ c.commits }}</span>
</a>
</div>
</template>
<style scoped>
.contributor-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 16px 0;
}
.contributor-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 20px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
text-decoration: none;
color: var(--vp-c-text-1);
font-size: 14px;
transition: border-color 0.25s;
}
.contributor-item:hover {
border-color: var(--vp-c-brand-1);
}
.contributor-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.contributor-count {
font-size: 12px;
color: var(--vp-c-text-3);
}
</style>编辑链接增强
智能编辑链接组件
vue
<!-- docs/.vitepress/theme/components/EditLink.vue -->
<script setup lang="ts">
import { useData } from 'vitepress'
const { theme, page } = useData()
const repoUrl = theme.value.editLink?.pattern?.replace('/:path', '') || ''
const filePath = page.value.relativePath
const editUrl = theme.value.editLink?.pattern?.replace(':path', filePath) || ''
// 生成多种编辑方式的链接
const links = [
{
label: '在 GitHub 编辑',
url: editUrl,
icon: '✏️'
},
{
label: '在 StackBlitz 打开',
url: `https://stackblitz.com/github/${repoUrl.replace('https://github.com/', '')}`,
icon: '⚡'
},
{
label: '查看 Git 历史',
url: `${repoUrl.replace('/edit/', '/commits/')}${filePath}`,
icon: '📜'
}
]
</script>
<template>
<div class="edit-links">
<a
v-for="link in links"
:key="link.label"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="edit-link-item"
>
<span class="edit-link-icon">{{ link.icon }}</span>
{{ link.label }}
</a>
</div>
</template>
<style scoped>
.edit-links {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 16px 0;
padding-top: 16px;
border-top: 1px solid var(--vp-c-divider);
}
.edit-link-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--vp-c-text-2);
text-decoration: none;
transition: color 0.25s;
}
.edit-link-item:hover {
color: var(--vp-c-brand-1);
}
.edit-link-icon {
font-size: 14px;
}
</style>内容发布流程
发布检查脚本
typescript
// scripts/pre-publish.mts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
interface PublishCheck {
file: string
status: 'pass' | 'warn' | 'fail'
message: string
}
function checkPublishReadiness(docsDir: string): PublishCheck[] {
const checks: PublishCheck[] = []
const entries = fs.readdirSync(docsDir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(docsDir, entry.name)
if (entry.isDirectory() && entry.name !== '.vitepress' && entry.name !== 'node_modules') {
checks.push(...checkPublishReadiness(fullPath))
} else if (entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8')
const { data } = matter(content)
const relativePath = path.relative(docsDir, fullPath)
// 检查是否为草稿
if (data.draft === true) {
checks.push({
file: relativePath,
status: 'warn',
message: '草稿页面不会被发布'
})
}
// 检查是否有审核信息
if (!data.draft && data.review === false) {
checks.push({
file: relativePath,
status: 'fail',
message: '内容未通过审核'
})
}
// 检查图片是否存在
const imgRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
let match: RegExpExecArray | null
while ((match = imgRegex.exec(content)) !== null) {
const imgPath = match[2]
if (!imgPath.startsWith('http') && !imgPath.startsWith('/')) {
const resolvedPath = path.resolve(path.dirname(fullPath), imgPath)
if (!fs.existsSync(resolvedPath)) {
checks.push({
file: relativePath,
status: 'fail',
message: `图片不存在:${imgPath}`
})
}
}
}
}
}
return checks
}
const results = checkPublishReadiness('docs')
const failed = results.filter(r => r.status === 'fail')
const warned = results.filter(r => r.status === 'warn')
console.log('\n📋 发布检查报告\n')
console.log(` 通过: ${results.filter(r => r.status === 'pass').length}`)
console.log(` 警告: ${warned.length}`)
console.log(` 失败: ${failed.length}`)
if (warned.length) {
console.log('\n⚠️ 警告:')
warned.forEach(r => console.log(` ${r.file}: ${r.message}`))
}
if (failed.length) {
console.log('\n❌ 失败:')
failed.forEach(r => console.log(` ${r.file}: ${r.message}`))
process.exit(1)
}
console.log('\n✅ 内容发布检查通过')VitePress 配置集成
typescript
// docs/.vitepress/config.mts
import { defineConfig } from 'vitepress'
export default defineConfig({
// 排除草稿页面
srcExclude: ['**/draft-*.md', '**/_*.md'],
themeConfig: {
editLink: {
pattern: 'https://github.com/your-org/docs/edit/main/docs/:path',
text: '编辑此页'
},
lastUpdated: {
text: '最后更新于'
}
},
// 构建前检查
async buildEnd(siteConfig) {
// 运行发布检查
const { execSync } = await import('child_process')
try {
execSync('npx tsx scripts/pre-publish.mts', { stdio: 'inherit' })
} catch {
throw new Error('发布检查未通过,构建终止')
}
}
})团队协作最佳实践
1. 文档编写规范
| 规范 | 说明 | 检查方式 |
|---|---|---|
| Frontmatter 完整 | title、description、tags 必填 | CI 自动检查 |
| 代码块指定语言 | 所有代码块必须有语言标识 | 拼写检查脚本 |
| 图片 alt 文本 | 所有图片必须有描述 | Frontmatter 检查 |
| 链接有效 | 无死链 | 死链检查脚本 |
| 中英文间距 | 中文与英文之间留空格 | 代码审查 |
| 提交信息规范 | 遵循 Conventional Commits | Git hooks |
2. 审核标准
审核要点
- 内容准确性 — 技术描述是否正确
- 结构清晰 — 标题层级是否合理
- 代码可运行 — 示例代码能否直接运行
- 链接有效 — 内部链接是否可访问
- 风格一致 — 是否符合项目写作规范
3. 发布节奏
text
每周二、四发布:
1. 合并已审核的 PR
2. 运行完整 CI 检查
3. 构建并部署到预发布环境
4. 最终确认后发布到生产环境与 Headless CMS 协作
如果团队使用 Headless CMS(如 Contentful、Sanity)管理内容,可通过 Webhook 实现自动同步:
yaml
# .github/workflows/cms-sync.yml
name: CMS Content Sync
on:
repository_dispatch:
types: [content-updated]
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Sync from CMS
run: npx tsx scripts/sync-cms.mts
env:
CMS_API_KEY: ${{ secrets.CMS_API_KEY }}
- name: Create PR if changed
run: |
git diff --quiet || {
git checkout -b cms/sync-$(date +%Y%m%d)
git add .
git commit -m "chore: sync content from CMS"
git push origin cms/sync-$(date +%Y%m%d)
gh pr create --title "CMS 内容同步" --body "自动同步 CMS 最新内容"
}常见问题
Q: 多人同时修改同一文件怎么办?
Git 会自动处理合并,如果产生冲突,需要手动解决。建议:
- 频繁同步:编写前先
git pull - 小粒度提交:每次只修改一个主题
- 及时沟通:在 Issue 或 PR 中说明正在修改的文件
Q: 如何防止误发布?
- 使用
draft: true标记草稿 srcExclude排除草稿文件- CI 中添加发布前检查
- 生产部署需要手动确认
Q: 如何追踪内容变更历史?
- 使用 Git blame 查看每行修改者
- 在 Frontmatter 中记录
updated日期 - 使用贡献者列表展示所有参与者
- CI 中生成变更日志
相关链接
- 多作者博客搭建 — 多作者体系设计
- CI/CD 自动化部署 — 部署自动化
- CMS 集成 — Headless CMS 方案
- 调试与排错 — 故障排查
- 贡献指南 — 本项目贡献规范