辣评用户手册系统的实现(十一)

 

历史说明

本文主要记录 2025 年阶段的用户手册设计方案与接口草案。
后续在 2026 年迭代中,手册系统主线实现调整为“文件型内容管理”(docs/manual),并采用后台管理接口 /api/admin/manual*
因此,本文中的部分数据模型与接口示例应理解为阶段性方案,用于保留技术演进背景。


用户手册系统概述

用户手册系统为平台用户提供了完整的使用指南和帮助文档。系统支持在线编辑、章节管理、导航等功能。

系统目标

  • 提供完整的用户指南
  • 支持在线编辑功能
  • 实现灵活的章节管理
  • 提供良好的导航体验

手册内容管理

数据模型

// Manual 用户手册模型
type Manual struct {
    ID              uint      `gorm:"primaryKey"`
    Title           string    `gorm:"index"`
    Description     string    `gorm:"type:text"`
    Content         string    `gorm:"type:longtext"`
    Version         int       // 版本号
    IsPublished     bool      // 是否已发布
    PublishedAt     *time.Time
    CreatedBy       uint      // 创建者
    UpdatedBy       uint      // 更新者
    CreatedAt       time.Time
    UpdatedAt       time.Time
    DeletedAt       gorm.DeletedAt `gorm:"index"`
}

// ManualChapter 手册章节模型
type ManualChapter struct {
    ID              uint      `gorm:"primaryKey"`
    ManualID        uint      `gorm:"index"`
    Title           string
    Content         string    `gorm:"type:longtext"`
    Order           int       // 章节顺序
    Level           int       // 层级(1-3)
    ParentID        *uint     // 父章节ID
    IsVisible       bool      // 是否可见
    CreatedAt       time.Time
    UpdatedAt       time.Time
    
    // 关联
    Manual          *Manual
    Children        []ManualChapter `gorm:"foreignKey:ParentID"`
}

// ManualVersion 手册版本历史
type ManualVersion struct {
    ID              uint
    ManualID        uint
    VersionNumber   int
    Content         string    `gorm:"type:longtext"`
    ChangeSummary   string
    CreatedBy       uint
    CreatedAt       time.Time
}

章节导航设计

章节树结构

// GetManualChapters 获取手册章节树
func (s *ManualService) GetManualChapters(ctx context.Context, manualID uint) ([]ManualChapter, error) {
    var chapters []ManualChapter
    
    // 获取所有章节
    if err := s.db.Where("manual_id = ? AND is_visible = ?", manualID, true).
        Order("order ASC").
        Find(&chapters).Error; err != nil {
        return nil, err
    }
    
    // 构建树结构
    return s.buildChapterTree(chapters), nil
}

// buildChapterTree 构建章节树
func (s *ManualService) buildChapterTree(chapters []ManualChapter) []ManualChapter {
    // 创建 map 用于快速查找
    chapterMap := make(map[uint]*ManualChapter)
    for i := range chapters {
        chapterMap[chapters[i].ID] = &chapters[i]
    }
    
    // 构建树结构
    var roots []ManualChapter
    for _, chapter := range chapters {
        if chapter.ParentID == nil {
            roots = append(roots, chapter)
        } else if parent, ok := chapterMap[*chapter.ParentID]; ok {
            parent.Children = append(parent.Children, chapter)
        }
    }
    
    return roots
}

// GetChapterByID 获取章节详情
func (s *ManualService) GetChapterByID(ctx context.Context, chapterID uint) (*ManualChapter, error) {
    chapter := &ManualChapter{}
    
    if err := s.db.Preload("Children").
        First(chapter, chapterID).Error; err != nil {
        return nil, err
    }
    
    return chapter, nil
}

导航组件

<template>
  <div class="manual-navigation">
    <div class="nav-tree">
      <div v-for="chapter in chapters" :key="chapter.id" class="nav-item">
        <div class="nav-title" @click="selectChapter(chapter)">
          
          <i v-if="chapter.children.length > 0" 
             :class="{ 'expanded': expandedIds.includes(chapter.id) }"
             class="icon-arrow"></i>
        </div>
        <div v-if="expandedIds.includes(chapter.id)" class="nav-children">
          <div v-for="child in chapter.children" :key="child.id" class="nav-item">
            <div class="nav-title" @click="selectChapter(child)">
              
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    chapters: Array
  },
  data() {
    return {
      expandedIds: []
    }
  },
  methods: {
    selectChapter(chapter) {
      this.$emit('chapter-select', chapter)
    },
    toggleExpand(chapterId) {
      const index = this.expandedIds.indexOf(chapterId)
      if (index > -1) {
        this.expandedIds.splice(index, 1)
      } else {
        this.expandedIds.push(chapterId)
      }
    }
  }
}
</script>

<style scoped>
.manual-navigation {
  width: 250px;
  border-right: 1px solid #eee;
  padding: 20px;
}

.nav-item {
  margin-bottom: 10px;
}

.nav-title {
  cursor: pointer;
  padding: 8px;
  border-radius: 4px;
  transition: background-color 0.3s;
}

.nav-title:hover {
  background-color: #f5f5f5;
}

.nav-children {
  margin-left: 20px;
  margin-top: 5px;
}

.icon-arrow {
  display: inline-block;
  transition: transform 0.3s;
}

.icon-arrow.expanded {
  transform: rotate(90deg);
}
</style>

在线编辑功能

编辑器实现

<template>
  <div class="manual-editor">
    <div class="editor-toolbar">
      <el-button @click="saveChapter">保存</el-button>
      <el-button @click="previewChapter">预览</el-button>
      <el-button @click="addSubChapter">添加子章节</el-button>
      <el-button type="danger" @click="deleteChapter">删除</el-button>
    </div>
    <div class="editor-content">
      <el-input v-model="chapter.title" placeholder="章节标题"></el-input>
      <el-input 
        v-model="chapter.content" 
        type="textarea" 
        :rows="20"
        placeholder="章节内容"
      ></el-input>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    chapter: Object
  },
  methods: {
    async saveChapter() {
      try {
        await this.$api.updateManualChapter(this.chapter.id, {
          title: this.chapter.title,
          content: this.chapter.content
        })
        this.$message.success('保存成功')
        this.$emit('chapter-updated', this.chapter)
      } catch (error) {
        this.$message.error('保存失败')
      }
    },
    previewChapter() {
      this.$emit('preview', this.chapter)
    },
    async addSubChapter() {
      const title = await this.$prompt('请输入子章节标题')
      try {
        const newChapter = await this.$api.createManualChapter({
          manual_id: this.chapter.manual_id,
          parent_id: this.chapter.id,
          title: title
        })
        this.$message.success('子章节已创建')
        this.$emit('chapter-created', newChapter)
      } catch (error) {
        this.$message.error('创建失败')
      }
    },
    async deleteChapter() {
      const confirm = await this.$confirm('确定删除此章节吗?')
      if (confirm) {
        try {
          await this.$api.deleteManualChapter(this.chapter.id)
          this.$message.success('删除成功')
          this.$emit('chapter-deleted', this.chapter.id)
        } catch (error) {
          this.$message.error('删除失败')
        }
      }
    }
  }
}
</script>

<style scoped>
.manual-editor {
  flex: 1;
  padding: 20px;
}

.editor-toolbar {
  margin-bottom: 20px;
}

.editor-content {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
</style>

内容组织结构

手册发布流程

// PublishManual 发布手册
func (s *ManualService) PublishManual(ctx context.Context, manualID uint) error {
    // 1. 验证手册内容完整性
    manual := &Manual{}
    if err := s.db.First(manual, manualID).Error; err != nil {
        return err
    }
    
    if manual.Title == "" || manual.Content == "" {
        return errors.New("手册内容不完整")
    }
    
    // 2. 验证所有章节
    var chapters []ManualChapter
    if err := s.db.Where("manual_id = ?", manualID).Find(&chapters).Error; err != nil {
        return err
    }
    
    for _, chapter := range chapters {
        if chapter.Title == "" || chapter.Content == "" {
            return fmt.Errorf("章节 %s 内容不完整", chapter.Title)
        }
    }
    
    // 3. 创建版本记录
    version := &ManualVersion{
        ManualID:      manualID,
        VersionNumber: manual.Version + 1,
        Content:       manual.Content,
        ChangeSummary: "发布版本",
        CreatedBy:     getUserID(ctx),
    }
    
    if err := s.db.Create(version).Error; err != nil {
        return err
    }
    
    // 4. 更新手册状态
    now := time.Now()
    return s.db.Model(manual).Updates(map[string]interface{}{
        "is_published": true,
        "published_at": now,
        "version":      manual.Version + 1,
    }).Error
}

// GetManualVersions 获取手册版本历史
func (s *ManualService) GetManualVersions(ctx context.Context, manualID uint) ([]ManualVersion, error) {
    var versions []ManualVersion
    
    if err := s.db.Where("manual_id = ?", manualID).
        Order("version_number DESC").
        Find(&versions).Error; err != nil {
        return nil, err
    }
    
    return versions, nil
}

// RestoreManualVersion 恢复手册版本
func (s *ManualService) RestoreManualVersion(ctx context.Context, versionID uint) error {
    version := &ManualVersion{}
    if err := s.db.First(version, versionID).Error; err != nil {
        return err
    }
    
    manual := &Manual{}
    if err := s.db.First(manual, version.ManualID).Error; err != nil {
        return err
    }
    
    // 更新手册内容
    return s.db.Model(manual).Updates(map[string]interface{}{
        "content": version.Content,
        "version": version.VersionNumber,
    }).Error
}

前后端协作

API 接口

说明:以下接口为当期设计稿接口。当前主线实现请参考后续文档《用户手册系统完善(二十一)》及代码中的 /api/admin/manual 系列接口。

GET /api/manuals - 获取手册列表
GET /api/manuals/{id} - 获取手册详情
GET /api/manuals/{id}/chapters - 获取手册章节
POST /api/manuals - 创建手册
PUT /api/manuals/{id} - 更新手册
DELETE /api/manuals/{id} - 删除手册

POST /api/manuals/{id}/chapters - 创建章节
PUT /api/manuals/chapters/{id} - 更新章节
DELETE /api/manuals/chapters/{id} - 删除章节

POST /api/manuals/{id}/publish - 发布手册
GET /api/manuals/{id}/versions - 获取版本历史
POST /api/manuals/versions/{id}/restore - 恢复版本

总结

用户手册系统为平台提供了完整的帮助文档功能,支持在线编辑和版本管理,提升了用户体验。

关键特性

  • 灵活的章节管理
  • 在线编辑功能
  • 版本控制
  • 树形导航
  • 发布流程

这个系统为用户提供了便捷的学习和参考资源。

本文遵守 Attribution-NonCommercial 4.0 International 许可协议。 Attribution-NonCommercial 4.0 International