实现路径说明
本文保留了重构阶段的目录规划示例(如 manuals/ 结构)用于说明演进思路。
当前主线代码中,用户手册内容路径为 docs/manual/,后台接口为 /api/admin/manual*。
用户手册系统完善概述
在用户手册系统的初步实现后(参见第十一篇文档),我们根据实际使用反馈进行了全面的完善和优化。本次完善主要集中在内容重构、章节管理优化、在线编辑增强和导航体验改进等方面。
完善目标
- 重构手册内容结构
- 优化章节管理功能
- 增强在线编辑体验
- 改进导航和目录
- 提升前后端协作效率
手册内容重构
问题分析
原有问题:
- 所有内容存储在单一文件中,难以维护
- 章节结构不够清晰
- 内容更新困难
- 版本管理不便
重构目标:
- 将内容拆分为多个独立文件
- 建立清晰的章节层级
- 支持独立编辑和更新
- 便于版本控制
新的内容结构
说明:以下目录为重构阶段的规划示例,当前仓库落地路径请以
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(`')
}
}
// 预览
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 编辑器
- 实时预览功能
- 自动保存草稿
用户反馈
- 内容更新更加便捷
- 编辑体验显著提升
- 导航更加清晰
- 查找内容更加快速
这次完善为用户提供了更好的帮助文档体验,也为管理员提供了更高效的内容管理工具。