Skip to content

协同编辑实战案例

当文档站从个人项目发展为团队协作时,需要一套完整的协同编辑机制:多人同时编写内容、自动化质量检查、内容审核流程和版本化发布。本文将搭建一套基于 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.mts

Frontmatter 校验脚本

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
  // 匹配图片链接 ![alt](url)
  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 CommitsGit hooks

2. 审核标准

审核要点

  1. 内容准确性 — 技术描述是否正确
  2. 结构清晰 — 标题层级是否合理
  3. 代码可运行 — 示例代码能否直接运行
  4. 链接有效 — 内部链接是否可访问
  5. 风格一致 — 是否符合项目写作规范

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 会自动处理合并,如果产生冲突,需要手动解决。建议:

  1. 频繁同步:编写前先 git pull
  2. 小粒度提交:每次只修改一个主题
  3. 及时沟通:在 Issue 或 PR 中说明正在修改的文件

Q: 如何防止误发布?

  1. 使用 draft: true 标记草稿
  2. srcExclude 排除草稿文件
  3. CI 中添加发布前检查
  4. 生产部署需要手动确认

Q: 如何追踪内容变更历史?

  1. 使用 Git blame 查看每行修改者
  2. 在 Frontmatter 中记录 updated 日期
  3. 使用贡献者列表展示所有参与者
  4. CI 中生成变更日志

相关链接

贡献者

加载中...

想要成为贡献者?

在 CNB 上参与贡献