辣评用户手册系统完善(二十一)

 

实现路径说明

本文保留了重构阶段的目录规划示例(如 manuals/ 结构)用于说明演进思路。
当前主线代码中,用户手册内容路径为 docs/manual/,后台接口为 /api/admin/manual*


用户手册系统完善概述

在用户手册系统的初步实现后(参见第十一篇文档),我们根据实际使用反馈进行了全面的完善和优化。本次完善主要集中在内容重构、章节管理优化、在线编辑增强和导航体验改进等方面。

完善目标

  • 重构手册内容结构
  • 优化章节管理功能
  • 增强在线编辑体验
  • 改进导航和目录
  • 提升前后端协作效率

手册内容重构

问题分析

原有问题:

  1. 所有内容存储在单一文件中,难以维护
  2. 章节结构不够清晰
  3. 内容更新困难
  4. 版本管理不便

重构目标:

  1. 将内容拆分为多个独立文件
  2. 建立清晰的章节层级
  3. 支持独立编辑和更新
  4. 便于版本控制

新的内容结构

说明:以下目录为重构阶段的规划示例,当前仓库落地路径请以 docs/manual/ 为准。

manuals/
├── index.json              # 手册索引
├── chapters/
│   ├── 01-getting-started/ # 第一章:快速开始
│   │   ├── index.md
│   │   ├── 01-registration.md
│   │   ├── 02-login.md
│   │   └── 03-profile.md
│   ├── 02-submission/      # 第二章:投稿指南
│   │   ├── index.md
│   │   ├── 01-create.md
│   │   ├── 02-edit.md
│   │   └── 03-rules.md
│   ├── 03-comment/         # 第三章:评论指南
│   │   ├── index.md
│   │   ├── 01-write.md
│   │   ├── 02-score.md
│   │   └── 03-quality.md
│   └── 04-qualification/   # 第四章:资格认证
│       ├── index.md
│       ├── 01-author.md
│       └── 02-reader.md
└── assets/
    └── images/             # 手册图片资源

索引文件结构

{
  "version": "1.0.0",
  "lastUpdated": "2026-02-28",
  "chapters": [
    {
      "id": "getting-started",
      "title": "快速开始",
      "order": 1,
      "sections": [
        {
          "id": "registration",
          "title": "注册账号",
          "file": "chapters/01-getting-started/01-registration.md",
          "order": 1
        },
        {
          "id": "login",
          "title": "登录系统",
          "file": "chapters/01-getting-started/02-login.md",
          "order": 2
        },
        {
          "id": "profile",
          "title": "个人资料",
          "file": "chapters/01-getting-started/03-profile.md",
          "order": 3
        }
      ]
    },
    {
      "id": "submission",
      "title": "投稿指南",
      "order": 2,
      "sections": [
        {
          "id": "create",
          "title": "创建投稿",
          "file": "chapters/02-submission/01-create.md",
          "order": 1
        },
        {
          "id": "edit",
          "title": "编辑投稿",
          "file": "chapters/02-submission/02-edit.md",
          "order": 2
        },
        {
          "id": "rules",
          "title": "投稿规则",
          "file": "chapters/02-submission/03-rules.md",
          "order": 3
        }
      ]
    }
  ]
}

章节管理优化

后端接口实现

// cmd/server/handlers/manual_handler.go

// ManualIndex 手册索引结构
type ManualIndex struct {
    Version     string    `json:"version"`
    LastUpdated string    `json:"lastUpdated"`
    Chapters    []Chapter `json:"chapters"`
}

type Chapter struct {
    ID       string    `json:"id"`
    Title    string    `json:"title"`
    Order    int       `json:"order"`
    Sections []Section `json:"sections"`
}

type Section struct {
    ID    string `json:"id"`
    Title string `json:"title"`
    File  string `json:"file"`
    Order int    `json:"order"`
}

// GetManualIndex 获取手册索引
func (h *ManualHandler) GetManualIndex(c *gin.Context) {
    indexPath := filepath.Join(h.manualPath, "index.json")

    data, err := os.ReadFile(indexPath)
    if err != nil {
        c.JSON(500, gin.H{
            "code":    500,
            "message": "读取索引失败",
            "error":   err.Error(),
        })
        return
    }

    var index ManualIndex
    if err := json.Unmarshal(data, &index); err != nil {
        c.JSON(500, gin.H{
            "code":    500,
            "message": "解析索引失败",
            "error":   err.Error(),
        })
        return
    }

    c.JSON(200, gin.H{
        "code": 200,
        "data": index,
    })
}

// GetManualSection 获取章节内容
func (h *ManualHandler) GetManualSection(c *gin.Context) {
    chapterID := c.Param("chapterId")
    sectionID := c.Param("sectionId")

    // 1. 读取索引找到文件路径
    index, err := h.loadManualIndex()
    if err != nil {
        c.JSON(500, gin.H{
            "code":    500,
            "message": "读取索引失败",
        })
        return
    }

    // 2. 查找章节和小节
    var filePath string
    for _, chapter := range index.Chapters {
        if chapter.ID == chapterID {
            for _, section := range chapter.Sections {
                if section.ID == sectionID {
                    filePath = section.File
                    break
                }
            }
            break
        }
    }

    if filePath == "" {
        c.JSON(404, gin.H{
            "code":    404,
            "message": "章节不存在",
        })
        return
    }

    // 3. 读取文件内容
    fullPath := filepath.Join(h.manualPath, filePath)
    content, err := os.ReadFile(fullPath)
    if err != nil {
        c.JSON(500, gin.H{
            "code":    500,
            "message": "读取内容失败",
        })
        return
    }

    c.JSON(200, gin.H{
        "code": 200,
        "data": gin.H{
            "chapterId": chapterID,
            "sectionId": sectionID,
            "content":   string(content),
        },
    })
}

// UpdateManualSection 更新章节内容
func (h *ManualHandler) UpdateManualSection(c *gin.Context) {
    // 验证管理员权限
    if !h.isAdmin(c) {
        c.JSON(403, gin.H{
            "code":    403,
            "message": "权限不足",
        })
        return
    }

    chapterID := c.Param("chapterId")
    sectionID := c.Param("sectionId")

    var req struct {
        Content string `json:"content" binding:"required"`
    }

    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{
            "code":    400,
            "message": "参数错误",
        })
        return
    }

    // 1. 查找文件路径
    index, err := h.loadManualIndex()
    if err != nil {
        c.JSON(500, gin.H{
            "code":    500,
            "message": "读取索引失败",
        })
        return
    }

    var filePath string
    for _, chapter := range index.Chapters {
        if chapter.ID == chapterID {
            for _, section := range chapter.Sections {
                if section.ID == sectionID {
                    filePath = section.File
                    break
                }
            }
            break
        }
    }

    if filePath == "" {
        c.JSON(404, gin.H{
            "code":    404,
            "message": "章节不存在",
        })
        return
    }

    // 2. 备份原文件
    fullPath := filepath.Join(h.manualPath, filePath)
    backupPath := fullPath + ".backup." + time.Now().Format("20060102150405")
    if err := h.backupFile(fullPath, backupPath); err != nil {
        c.JSON(500, gin.H{
            "code":    500,
            "message": "备份失败",
        })
        return
    }

    // 3. 写入新内容
    if err := os.WriteFile(fullPath, []byte(req.Content), 0644); err != nil {
        c.JSON(500, gin.H{
            "code":    500,
            "message": "保存失败",
        })
        return
    }

    // 4. 更新索引的最后更新时间
    index.LastUpdated = time.Now().Format("2006-01-02")
    if err := h.saveManualIndex(index); err != nil {
        c.JSON(500, gin.H{
            "code":    500,
            "message": "更新索引失败",
        })
        return
    }

    c.JSON(200, gin.H{
        "code":    200,
        "message": "保存成功",
    })
}

在线编辑增强

编辑器组件优化

<template>
  <div class="manual-editor">
    <div class="editor-header">
      <div class="breadcrumb">
        <el-breadcrumb separator="/">
          <el-breadcrumb-item>用户手册</el-breadcrumb-item>
          <el-breadcrumb-item></el-breadcrumb-item>
          <el-breadcrumb-item></el-breadcrumb-item>
        </el-breadcrumb>
      </div>
      <div class="actions">
        <el-button @click="handlePreview">
          <el-icon><View /></el-icon>
          预览
        </el-button>
        <el-button type="primary" @click="handleSave" :loading="saving">
          <el-icon><Check /></el-icon>
          保存
        </el-button>
      </div>
    </div>

    <div class="editor-body">
      <!-- Markdown 编辑器 -->
      <div class="editor-panel">
        <div class="toolbar">
          <el-button-group>
            <el-button size="small" @click="insertMarkdown('**', '**')">
              <el-icon><Bold /></el-icon>
            </el-button>
            <el-button size="small" @click="insertMarkdown('*', '*')">
              <el-icon><Italic /></el-icon>
            </el-button>
            <el-button size="small" @click="insertMarkdown('`', '`')">
              <el-icon><Code /></el-icon>
            </el-button>
          </el-button-group>
          <el-button-group style="margin-left: 8px">
            <el-button size="small" @click="insertMarkdown('# ', '')">H1</el-button>
            <el-button size="small" @click="insertMarkdown('## ', '')">H2</el-button>
            <el-button size="small" @click="insertMarkdown('### ', '')">H3</el-button>
          </el-button-group>
          <el-button-group style="margin-left: 8px">
            <el-button size="small" @click="insertMarkdown('- ', '')">
              <el-icon><List /></el-icon>
            </el-button>
            <el-button size="small" @click="insertMarkdown('1. ', '')">
              <el-icon><OrderedList /></el-icon>
            </el-button>
          </el-button-group>
          <el-button size="small" style="margin-left: 8px" @click="insertLink">
            <el-icon><Link /></el-icon>
            链接
          </el-button>
          <el-button size="small" @click="insertImage">
            <el-icon><Picture /></el-icon>
            图片
          </el-button>
        </div>

        <el-input
          v-model="content"
          type="textarea"
          :rows="25"
          placeholder="请输入 Markdown 内容"
          @input="handleContentChange"
        />

        <div class="editor-footer">
          <span class="word-count">字数:</span>
          <span class="last-saved" v-if="lastSaved">
            最后保存:
          </span>
        </div>
      </div>

      <!-- 预览面板 -->
      <div class="preview-panel" v-if="showPreview">
        <div class="preview-header">
          <span>预览</span>
          <el-button text @click="showPreview = false">
            <el-icon><Close /></el-icon>
          </el-button>
        </div>
        <div class="preview-content markdown-body" v-html="renderedContent"></div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { marked } from 'marked'
import { updateManualSection } from '@/api/manual'

const props = defineProps({
  chapterId: String,
  sectionId: String,
  initialContent: String,
  currentChapter: Object,
  currentSection: Object
})

const emit = defineEmits(['saved'])

const content = ref(props.initialContent || '')
const showPreview = ref(false)
const saving = ref(false)
const lastSaved = ref(null)

// 计算字数
const wordCount = computed(() => {
  return content.value.length
})

// 渲染 Markdown
const renderedContent = computed(() => {
  return marked(content.value)
})

// 监听内容变化(自动保存草稿)
let autoSaveTimer = null
watch(content, () => {
  clearTimeout(autoSaveTimer)
  autoSaveTimer = setTimeout(() => {
    saveDraft()
  }, 5000) // 5秒后自动保存草稿
})

// 插入 Markdown 语法
const insertMarkdown = (before, after) => {
  const textarea = document.querySelector('textarea')
  const start = textarea.selectionStart
  const end = textarea.selectionEnd
  const selectedText = content.value.substring(start, end)

  const newText = content.value.substring(0, start) +
    before + selectedText + after +
    content.value.substring(end)

  content.value = newText

  // 恢复光标位置
  setTimeout(() => {
    textarea.focus()
    textarea.setSelectionRange(
      start + before.length,
      end + before.length
    )
  }, 0)
}

// 插入链接
const insertLink = () => {
  const url = prompt('请输入链接地址:')
  if (url) {
    const text = prompt('请输入链接文本:', url)
    insertMarkdown(`[${text}](`, ')')
  }
}

// 插入图片
const insertImage = () => {
  const url = prompt('请输入图片地址:')
  if (url) {
    const alt = prompt('请输入图片描述:', '图片')
    insertMarkdown(`![${alt}](`, ')')
  }
}

// 预览
const handlePreview = () => {
  showPreview.value = !showPreview.value
}

// 保存
const handleSave = async () => {
  saving.value = true
  try {
    await updateManualSection(props.chapterId, props.sectionId, {
      content: content.value
    })

    lastSaved.value = new Date().toLocaleTimeString()
    ElMessage.success('保存成功')
    emit('saved')
  } catch (error) {
    ElMessage.error('保存失败')
  } finally {
    saving.value = false
  }
}

// 保存草稿(本地存储)
const saveDraft = () => {
  const key = `manual_draft_${props.chapterId}_${props.sectionId}`
  localStorage.setItem(key, content.value)
}

// 加载草稿
const loadDraft = () => {
  const key = `manual_draft_${props.chapterId}_${props.sectionId}`
  const draft = localStorage.getItem(key)
  if (draft && draft !== props.initialContent) {
    ElMessageBox.confirm(
      '检测到未保存的草稿,是否恢复?',
      '提示',
      {
        confirmButtonText: '恢复',
        cancelButtonText: '放弃',
        type: 'info'
      }
    ).then(() => {
      content.value = draft
    }).catch(() => {
      localStorage.removeItem(key)
    })
  }
}

// 内容变化处理
const handleContentChange = () => {
  // 可以添加实时预览等功能
}

onMounted(() => {
  loadDraft()
})
</script>

<style scoped>
.manual-editor {
  height: 100%;
  display: flex;
  flex-direction: column;
}

.editor-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  background-color: #fff;
  border-bottom: 1px solid #ebeef5;
}

.editor-body {
  flex: 1;
  display: flex;
  overflow: hidden;
}

.editor-panel {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: 16px;
  overflow: auto;
}

.toolbar {
  margin-bottom: 12px;
  padding-bottom: 12px;
  border-bottom: 1px solid #ebeef5;
}

.editor-footer {
  display: flex;
  justify-content: space-between;
  margin-top: 8px;
  font-size: 12px;
  color: #909399;
}

.preview-panel {
  flex: 1;
  display: flex;
  flex-direction: column;
  border-left: 1px solid #ebeef5;
  background-color: #fafafa;
}

.preview-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  background-color: #fff;
  border-bottom: 1px solid #ebeef5;
}

.preview-content {
  flex: 1;
  padding: 16px;
  overflow: auto;
}

/* Markdown 样式 */
.markdown-body {
  line-height: 1.8;
  color: #333;
}

.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
  margin-top: 24px;
  margin-bottom: 16px;
  font-weight: bold;
}

.markdown-body h1 { font-size: 28px; }
.markdown-body h2 { font-size: 24px; }
.markdown-body h3 { font-size: 20px; }

.markdown-body p {
  margin-bottom: 16px;
}

.markdown-body code {
  padding: 2px 4px;
  background-color: #f5f7fa;
  border-radius: 3px;
  font-family: 'Courier New', monospace;
}

.markdown-body pre {
  padding: 16px;
  background-color: #f5f7fa;
  border-radius: 4px;
  overflow-x: auto;
}

.markdown-body ul,
.markdown-body ol {
  margin-bottom: 16px;
  padding-left: 24px;
}

.markdown-body li {
  margin-bottom: 8px;
}
</style>

导航体验改进

左侧目录导航

<template>
  <div class="manual-navigation">
    <div class="nav-header">
      <h3>用户手册</h3>
      <el-tag size="small"></el-tag>
    </div>

    <el-scrollbar class="nav-scrollbar">
      <div class="nav-tree">
        <div
          v-for="chapter in chapters"
          :key="chapter.id"
          class="chapter-item"
        >
          <div
            class="chapter-title"
            :class="{ active: currentChapter === chapter.id }"
            @click="toggleChapter(chapter.id)"
          >
            <el-icon class="expand-icon" :class="{ expanded: expandedChapters.includes(chapter.id) }">
              <ArrowRight />
            </el-icon>
            <span></span>
          </div>

          <transition name="slide">
            <div
              v-show="expandedChapters.includes(chapter.id)"
              class="section-list"
            >
              <div
                v-for="section in chapter.sections"
                :key="section.id"
                class="section-item"
                :class="{ active: currentSection === section.id }"
                @click="selectSection(chapter.id, section.id)"
              >
                
              </div>
            </div>
          </transition>
        </div>
      </div>
    </el-scrollbar>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { ArrowRight } from '@element-plus/icons-vue'

const props = defineProps({
  chapters: Array,
  version: String,
  currentChapter: String,
  currentSection: String
})

const emit = defineEmits(['section-select'])

const expandedChapters = ref([])

// 切换章节展开/收起
const toggleChapter = (chapterId) => {
  const index = expandedChapters.value.indexOf(chapterId)
  if (index > -1) {
    expandedChapters.value.splice(index, 1)
  } else {
    expandedChapters.value.push(chapterId)
  }
}

// 选择章节
const selectSection = (chapterId, sectionId) => {
  emit('section-select', { chapterId, sectionId })
}

onMounted(() => {
  // 默认展开当前章节
  if (props.currentChapter) {
    expandedChapters.value.push(props.currentChapter)
  }
})
</script>

<style scoped>
.manual-navigation {
  width: 280px;
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: #fff;
  border-right: 1px solid #ebeef5;
}

.nav-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  border-bottom: 1px solid #ebeef5;
}

.nav-header h3 {
  margin: 0;
  font-size: 16px;
}

.nav-scrollbar {
  flex: 1;
}

.nav-tree {
  padding: 8px;
}

.chapter-item {
  margin-bottom: 4px;
}

.chapter-title {
  display: flex;
  align-items: center;
  padding: 10px 12px;
  cursor: pointer;
  border-radius: 4px;
  transition: all 0.3s;
}

.chapter-title:hover {
  background-color: #f5f7fa;
}

.chapter-title.active {
  background-color: #ecf5ff;
  color: #409eff;
}

.expand-icon {
  margin-right: 8px;
  transition: transform 0.3s;
}

.expand-icon.expanded {
  transform: rotate(90deg);
}

.section-list {
  margin-left: 24px;
  margin-top: 4px;
}

.section-item {
  padding: 8px 12px;
  cursor: pointer;
  border-radius: 4px;
  font-size: 14px;
  color: #606266;
  transition: all 0.3s;
}

.section-item:hover {
  background-color: #f5f7fa;
}

.section-item.active {
  background-color: #ecf5ff;
  color: #409eff;
}

/* 动画 */
.slide-enter-active,
.slide-leave-active {
  transition: all 0.3s ease;
}

.slide-enter-from,
.slide-leave-to {
  opacity: 0;
  transform: translateY(-10px);
}
</style>

总结

用户手册系统的完善显著提升了内容管理的效率和用户体验。通过内容重构、章节管理优化、在线编辑增强和导航改进,我们打造了一个功能完善、易于维护的用户手册系统。

关键改进

  • 内容拆分为多个文件,便于维护
  • 章节管理更加灵活
  • 在线编辑功能更加强大
  • 导航体验更加友好
  • 支持 Markdown 格式

技术亮点

  • 文件系统管理内容
  • JSON 索引结构
  • Markdown 编辑器
  • 实时预览功能
  • 自动保存草稿

用户反馈

  • 内容更新更加便捷
  • 编辑体验显著提升
  • 导航更加清晰
  • 查找内容更加快速

这次完善为用户提供了更好的帮助文档体验,也为管理员提供了更高效的内容管理工具。

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