| name | document-html-editor |
| description | 专业文书模板HTML可视化编辑器设计与实现技能,专注于RuoYi-Vue项目的文书模板编辑场景,支持WYSIWYG编辑、源码编辑、占位符管理等。 |
文书模板HTML可视化编辑系统设计与实现
本技能帮助设计和实现一个专注于文书模板HTML编辑场景的可视化编辑系统,满足RuoYi-Vue移动卫生执法系统的实际需求。
核心需求分析
1.1 当前场景特点
使用场景:
- 场景:文书模板的HTML内容编辑
- 平台:RuoYi-Vue后台管理系统的el-drawer抽屉中
- 用户:系统管理员、卫生监督执法人员
- 内容:Word转换后的HTML模板,包含占位符
${variableName}
技术栈:
- Vue 2.6.12
- Element UI 2.15.14
- CKEditor 4.22.1(CDN加载)
- 已安装CKEditor 5 npm包
1.2 功能需求
核心功能(必须实现)
-
WYSIWYG可视化编辑
- 文本格式化(粗体、斜体、下划线、颜色等)
- 段落格式(标题、列表、对齐)
- 表格编辑
- 图片插入和调整
- 链接管理
-
源码编辑模式
-
双模式切换
- WYSIWYG ↔ 源码实时切换
- 内容双向同步
- 切换动画过渡
-
占位符管理
- 占位符识别
${variableName}
- 占位符高亮显示(黄色背景)
- 占位符列表展示
- 占位符插入和编辑
辅助功能(建议实现)
-
文件操作
-
模板变量管理
技术方案设计
2.1 架构设计
┌─────────────────────────────────────────────────────┐
│ 文书模板HTML可视化编辑器 │
├─────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌───────────────────┐ │
│ │ 工具栏 │ │ 占位符面板 │ │
│ │ Toolbar │ │ Placeholder │ │
│ └─────────────┘ └───────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ │ │
│ │ 编辑器核心 │ │
│ │ CKEditor WYSIWYG │ │
│ │ 或 │ │
│ │ 源码编辑器 │ │
│ │ │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────┐ ┌───────────────────┐ │
│ │ 模式切换 │ │ 状态栏 │ │
│ │ Tabs │ │ Status │ │
│ └─────────────┘ └───────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
2.2 组件结构
src/
├── components/
│ └── DocumentHtmlEditor/
│ ├── index.vue # 主组件
│ ├── Toolbar.vue # 工具栏组件
│ ├── EditorCore.vue # 编辑器核心
│ ├── PlaceholderPanel.vue # 占位符面板
│ ├── ModeSwitch.vue # 模式切换
│ └── styles/
│ └── editor.scss # 编辑器样式
└── views/
└── system/
└── document/
└── template/
└── edit.vue # 改造后的编辑页
2.3 数据流设计
用户操作
↓
CKEditor事件监听 (change)
↓
解析HTML内容
↓
提取占位符 ${variableName}
↓
更新状态 (sourceContent, placeholders)
↓
触发Vue响应式更新
↓
保存到父组件 (emit input)
详细实现方案
3.1 主组件设计(index.vue)
模板结构
<template>
<div class="document-html-editor">
<!-- 顶部工具栏 -->
<div class="editor-toolbar">
<Toolbar
:editor="editorInstance"
@action="handleToolbarAction"
/>
</div>
<!-- 主体内容区 -->
<div class="editor-body">
<!-- 左侧:编辑区 -->
<div class="editor-main">
<!-- 模式切换标签 -->
<ModeSwitch
v-model="currentMode"
@change="handleModeChange"
/>
<!-- WYSIWYG模式 -->
<div v-show="currentMode === 'wysiwyg'" class="wysiwyg-container">
<div ref="wysiwygRef" class="wysiwyg-editor"></div>
</div>
<!-- 源码模式 -->
<div v-show="currentMode === 'source'" class="source-container">
<SourceEditor
v-model="sourceContent"
@change="handleSourceChange"
/>
</div>
</div>
<!-- 右侧:占位符面板 -->
<div v-if="showPlaceholderPanel" class="editor-sidebar">
<PlaceholderPanel
:placeholders="placeholders"
@insert="handleInsertPlaceholder"
@edit="handleEditPlaceholder"
/>
</div>
</div>
<!-- 底部状态栏 -->
<div class="editor-status">
<span>模式:{{ currentMode === 'wysiwyg' ? '可视化编辑' : '源码编辑' }}</span>
<span>占位符数量:{{ placeholders.length }}</span>
<span>字数:{{ wordCount }}</span>
</div>
</div>
</template>
脚本逻辑
export default {
name: 'DocumentHtmlEditor',
props: {
value: { type: String, default: '' },
showPlaceholderPanel: { type: Boolean, default: true }
},
data() {
return {
editorInstance: null,
currentMode: 'wysiwyg',
sourceContent: '',
placeholders: [],
isUpdatingFromEditor: false
}
},
computed: {
wordCount() {
const text = this.stripHtml(this.sourceContent)
return text.length
}
},
watch: {
value: {
handler(newValue) {
if (this.editorInstance && newValue !== this.getEditorContent()) {
this.setEditorContent(newValue)
this.parsePlaceholders(newValue)
} else if (!this.editorInstance && newValue) {
this.sourceContent = newValue
this.parsePlaceholders(newValue)
}
},
immediate: true
}
},
mounted() {
this.initEditor()
},
beforeDestroy() {
this.destroyEditor()
},
methods: {
async initEditor() {
if (typeof window.CKEDITOR === 'undefined') {
await this.loadScript('https://cdn.ckeditor.com/4.22.1/standard/ckeditor.js')
}
this.$nextTick(() => {
this.createEditor()
})
},
createEditor() {
const config = {
language: 'zh-cn',
toolbar: [
['Source'],
['Bold', 'Italic', 'Underline', 'Strike'],
['NumberedList', 'BulletedList'],
['Link', 'Unlink'],
['Table'],
['Undo', 'Redo']
],
on: {
instanceReady: (evt) => {
this.editorInstance = evt.editor
if (this.value) {
this.setEditorContent(this.value)
this.parsePlaceholders(this.value)
}
this.$emit('ready', this.editorInstance)
},
change: () => {
this.handleEditorChange()
}
}
}
this.editorInstance = window.CKEDITOR.replace(this.$refs.wysiwygRef, config)
},
handleEditorChange() {
if (this.isUpdatingFromEditor) return
const content = this.getEditorContent()
if (this.currentMode === 'source') {
this.sourceContent = content
}
this.parsePlaceholders(content)
this.$emit('input', content)
this.$emit('change', content)
},
handleSourceChange(content) {
if (this.isUpdatingFromEditor) return
if (this.currentMode === 'wysiwyg' && this.editorInstance) {
this.isUpdatingFromEditor = true
this.setEditorContent(content)
this.$nextTick(() => {
this.isUpdatingFromEditor = false
})
}
this.parsePlaceholders(content)
this.$emit('input', content)
this.$emit('change', content)
},
parsePlaceholders(html) {
const regex = /\$\{([^}]+)\}/g
const matches = []
let match
while ((match = regex.exec(html)) !== null) {
matches.push({
name: match[0],
variable: match[1],
index: match.index
})
}
this.placeholders = matches
},
handleInsertPlaceholder(placeholder) {
if (this.currentMode === 'source') {
const cursor = this.$refs.sourceTextarea.selectionStart
const text = this.sourceContent
this.sourceContent = text.slice(0, cursor) + placeholder + text.slice(cursor)
} else if (this.editorInstance) {
this.editorInstance.insertText(placeholder)
}
},
handleModeChange(mode) {
if (mode === 'source' && this.editorInstance) {
this.sourceContent = this.getEditorContent()
} else if (mode === 'wysiwyg' && this.editorInstance) {
this.isUpdatingFromEditor = true
this.setEditorContent(this.sourceContent)
this.$nextTick(() => {
this.isUpdatingFromEditor = false
})
}
},
getEditorContent() {
return this.editorInstance ? this.editorInstance.getData() : ''
},
setEditorContent(content) {
if (this.editorInstance) {
this.editorInstance.setData(content)
}
},
loadScript(src) {
return new Promise((resolve, reject) => {
if (typeof window.CKEDITOR !== 'undefined') {
resolve()
return
}
const script = document.createElement('script')
script.src = src
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
},
stripHtml(html) {
const tmp = document.createElement('div')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
},
destroyEditor() {
if (this.editorInstance) {
this.editorInstance.destroy()
this.editorInstance = null
}
}
}
}
3.2 占位符面板组件(PlaceholderPanel.vue)
<template>
<div class="placeholder-panel">
<div class="panel-header">
<h4>模板变量</h4>
<el-button size="mini" @click="handleAdd">添加变量</el-button>
</div>
<div class="panel-body">
<el-empty v-if="placeholders.length === 0" description="未检测到占位符" />
<div v-else class="placeholder-list">
<div
v-for="(placeholder, index) in placeholders"
:key="index"
class="placeholder-item"
@click="handleClick(placeholder)"
>
<span class="placeholder-name">{{ placeholder.name }}</span>
<el-tag size="mini" type="info">{{ placeholder.variable }}</el-tag>
</div>
</div>
</div>
<!-- 变量说明 -->
<div class="panel-footer">
<el-alert
title="变量格式说明"
type="info"
:closable="false"
size="mini"
>
<template>
<p>使用 <code>${变量名}</code> 格式定义变量</p>
<p>示例:${被检查单位}、${检查日期}</p>
</template>
</el-alert>
</div>
</div>
</template>
<script>
export default {
name: 'PlaceholderPanel',
props: {
placeholders: {
type: Array,
default: () => []
}
},
methods: {
handleClick(placeholder) {
this.$emit('insert', placeholder.name)
},
handleAdd() {
this.$prompt('请输入变量名', '添加变量', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
inputErrorMessage: '变量名格式不正确'
}).then(({ value }) => {
this.$emit('insert', '${' + value + '}')
})
}
}
}
</script>
<style scoped>
.placeholder-panel {
width: 280px;
border-left: 1px solid #e4e7ed;
background: #fafafa;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 15px;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h4 {
margin: 0;
font-size: 14px;
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.placeholder-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.placeholder-item {
padding: 10px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.placeholder-item:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
}
.placeholder-name {
display: block;
font-family: Consolas, monospace;
color: #409eff;
font-size: 13px;
margin-bottom: 5px;
}
.panel-footer {
padding: 10px;
border-top: 1px solid #e4e7ed;
}
</style>
集成方案
4.1 edit.vue集成
<!-- 文书模板编辑页面 - HTML编辑部分 -->
<el-drawer
title="编辑HTML内容"
:visible.sync="htmlDrawerVisible"
size="90%"
direction="rtl"
>
<div class="html-drawer-container">
<DocumentHtmlEditor
v-model="htmlContentDraft"
:show-placeholder-panel="true"
@change="handleHtmlChange"
/>
<div class="drawer-footer">
<el-button @click="htmlDrawerVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveHtml">保存</el-button>
</div>
</div>
</el-drawer>
import DocumentHtmlEditor from '@/components/DocumentHtmlEditor/index.vue'
export default {
components: {
DocumentHtmlEditor
},
data() {
return {
htmlDrawerVisible: false,
htmlContentDraft: ''
}
},
methods: {
openHtmlDrawer() {
this.htmlContentDraft = this.form.htmlContent || ''
this.htmlDrawerVisible = true
},
handleHtmlChange(content) {
console.log('HTML内容变化:', content)
},
async handleSaveHtml() {
try {
await saveHtmlContent(this.form.id, this.htmlContentDraft)
this.$message.success('保存成功')
this.form.htmlContent = this.htmlContentDraft
this.htmlDrawerVisible = false
} catch (error) {
this.$message.error('保存失败')
}
}
}
}
性能优化
5.1 加载优化
-
CKEditor按需加载
- 仅在打开抽屉时加载CKEditor
- 使用CDN加速
-
占位符解析优化
-
内容同步优化
- 模式切换时延迟更新
- 使用isUpdatingFromEditor标志防止循环更新
5.2 渲染优化
-
CSS样式隔离
-
事件处理优化
- 使用debounce处理高频事件
- 及时清理事件监听
安全考虑
-
XSS防护
-
输入验证
测试计划
6.1 功能测试
| 测试项 | 测试方法 | 预期结果 |
|---|
| WYSIWYG编辑 | 输入文本 | 文本正确显示 |
| 源码编辑 | 切换到源码模式 | HTML源码正确显示 |
| 模式切换 | WYSIWYG→源码→WYSIWYG | 内容同步 |
| 占位符解析 | 输入${test} | 高亮显示在列表中 |
| 内容保存 | 编辑→保存→重新打开 | 内容一致 |
6.2 兼容性测试
- Chrome 80+
- Firefox 75+
- Safari 13+
- Edge 80+
部署说明
7.1 依赖安装
CKEditor通过CDN加载,无需额外安装npm包。
7.2 构建配置
生产环境建议:
- 将CKEditor内联到项目
- 使用webpack/vite进行打包
- 配置CDN加速
后续扩展
-
增强占位符功能
-
模板预览
-
版本管理
-
协作编辑
使用指南
基本使用
<DocumentHtmlEditor
v-model="htmlContent"
:show-placeholder-panel="true"
@change="handleChange"
@ready="handleReady"
/>
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|
| value | String | '' | HTML内容 |
| showPlaceholderPanel | Boolean | true | 是否显示占位符面板 |
Events
| 事件 | 参数 | 说明 |
|---|
| input | String | 内容变化(用于v-model) |
| change | String | 内容变化 |
| ready | Editor | 编辑器就绪 |
注意事项
- CKEditor版本:使用4.22.1(不安全但稳定)
- CDN依赖:需要网络连接加载CKEditor
- 样式隔离:编辑区域样式独立,不影响外部
- 内容同步:双模式切换时自动同步内容
- 占位符格式:严格遵循
${variableName}格式