C_Editor 富文本编辑器组件
📝 基于 WangEditor 的强大富文本编辑器,让内容创作变得简单而高效
✨ 特性
- 📝 所见即所得: 基于 WangEditor 的强大编辑能力,支持丰富的文本格式
- 🎨 自定义配置: 支持工具栏配置、主题定制、占位符等个性化设置
- 🔧 双向绑定: 完整的 v-model 支持,数据响应式更新
- 📱 响应式设计: 自适应不同屏幕尺寸,移动端友好
- 🔒 状态控制: 支持禁用、只读模式,灵活的权限控制
- 🌍 国际化: 内置多语言支持,轻松本地化
- 💪 TypeScript: 完整的类型定义和类型安全
- ⚡ 高性能: 优化的渲染机制和内存管理
- 🔌 插件系统: 支持扩展插件,功能可定制
- 🎯 事件丰富: 完整的生命周期和交互事件回调
📦 安装
bash
# 安装 WangEditor 相关依赖
bun add @wangeditor/editor @wangeditor/editor-for-vue
1
2
2
🎯 快速开始
基础用法
vue
<template>
<!-- 最简单的富文本编辑器 -->
<C_Editor
v-model="content"
placeholder="请输入内容..."
@editor-mounted="handleEditorMounted"
@editor-change="handleContentChange"
/>
</template>
<script setup>
const content = ref('<p>Hello World!</p>')
const handleEditorMounted = (editor) => {
console.log('编辑器已初始化:', editor)
}
const handleContentChange = (html) => {
console.log('内容已更新:', html)
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
完整功能示例
vue
<template>
<div class="editor-demo">
<!-- 控制面板 -->
<n-space class="mb-20px" align="center">
<n-switch v-model:value="editorConfig.disabled">
<template #checked>已禁用</template>
<template #unchecked>已启用</template>
</n-switch>
<n-switch v-model:value="editorConfig.readonly">
<template #checked>只读</template>
<template #unchecked>可编辑</template>
</n-switch>
<n-input-number
v-model:value="editorConfig.height"
:min="200"
:max="800"
:step="50"
style="width: 120px"
>
<template #prefix>高度</template>
</n-input-number>
<n-button type="primary" @click="insertSampleContent">
插入示例内容
</n-button>
<n-button type="warning" @click="clearContent"> 清空内容 </n-button>
</n-space>
<!-- 富文本编辑器 -->
<C_Editor
ref="editorRef"
v-model="editorContent"
:editor-id="editorId"
:placeholder="editorConfig.placeholder"
:height="editorConfig.height"
:disabled="editorConfig.disabled"
:readonly="editorConfig.readonly"
:toolbar-config="toolbarConfig"
:editor-config="customEditorConfig"
@editor-mounted="handleEditorMounted"
@editor-change="handleEditorChange"
@editor-focus="handleEditorFocus"
@editor-blur="handleEditorBlur"
class="demo-editor"
/>
<!-- 内容预览 -->
<n-card class="mt-20px" title="内容预览" size="small">
<n-space vertical>
<n-tag type="info">字符数: {{ contentLength }}</n-tag>
<n-tag type="success">HTML长度: {{ htmlLength }}</n-tag>
<n-collapse>
<n-collapse-item title="查看HTML源码" name="html">
<n-code :code="editorContent" language="html" />
</n-collapse-item>
</n-collapse>
</n-space>
</n-card>
</div>
</template>
<script setup>
const editorRef = ref()
const message = useMessage()
const dialog = useDialog()
// 编辑器配置
const editorId = ref('demo-editor-' + Date.now())
const editorContent = ref(`
<h2>欢迎使用富文本编辑器</h2>
<p>这是一个基于 <strong>WangEditor</strong> 封装的 Vue3 富文本编辑器组件。</p>
<h3>主要特性:</h3>
<ul>
<li>所见即所得编辑</li>
<li>丰富的工具栏功能</li>
<li>支持图片、视频、链接等媒体</li>
<li>完整的 Vue3 集成</li>
</ul>
`)
const editorConfig = reactive({
height: 400,
placeholder: '请输入内容...',
disabled: false,
readonly: false,
})
// 工具栏配置
const toolbarConfig = {
excludeKeys: [
'group-video', // 排除视频功能
],
}
// 编辑器高级配置
const customEditorConfig = {
MENU_CONF: {
uploadImage: {
server: '/api/upload-image',
fieldName: 'file',
maxFileSize: 5 * 1024 * 1024, // 5MB
allowedFileTypes: ['image/*'],
onSuccess: (file, res) => {
console.log('图片上传成功:', res)
},
onFailed: (file, res) => {
message.error('图片上传失败')
},
},
insertLink: {
checkLink: (text, link) => {
if (!link) return '链接不能为空'
if (!link.startsWith('http')) return '链接必须以 http 开头'
return true
},
},
},
}
// 计算属性
const contentLength = computed(() => {
if (!editorContent.value) return 0
return editorContent.value.replace(/<[^>]*>/g, '').length
})
const htmlLength = computed(() => {
return editorContent.value?.length || 0
})
// 事件处理函数
const handleEditorMounted = (editor) => {
console.log('编辑器挂载完成:', editor)
message.success('富文本编辑器初始化成功!')
}
const handleEditorChange = (html) => {
console.log('内容变化:', html.length + ' 字符')
}
const handleEditorFocus = () => {
console.log('编辑器获得焦点')
}
const handleEditorBlur = () => {
console.log('编辑器失去焦点')
}
// 操作方法
const insertSampleContent = () => {
const sampleContent = `
<h3>示例内容 - ${new Date().toLocaleString()}</h3>
<p>这是通过方法插入的示例内容,包含:</p>
<ol>
<li><strong>粗体文本</strong></li>
<li><em>斜体文本</em></li>
<li><u>下划线文本</u></li>
<li><span style="color: #ff6b6b;">彩色文本</span></li>
</ol>
<blockquote>
<p>这是一个引用块,用于突出重要信息。</p>
</blockquote>
`
if (editorRef.value) {
editorRef.value.setContent(sampleContent)
message.success('示例内容已插入')
}
}
const clearContent = () => {
dialog.warning({
title: '确认清空',
content: '确定要清空编辑器中的所有内容吗?此操作不可撤销。',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: () => {
if (editorRef.value) {
editorRef.value.setContent('')
message.success('内容已清空')
}
},
})
}
// 监听配置变化
watch(
() => editorConfig.disabled,
(disabled) => {
if (disabled) {
message.warning('编辑器已禁用')
} else {
message.success('编辑器已启用')
}
}
)
watch(
() => editorConfig.readonly,
(readonly) => {
if (readonly) {
message.info('编辑器已切换到只读模式')
} else {
message.success('编辑器已切换到编辑模式')
}
}
)
</script>
<style scoped>
.editor-demo {
padding: 20px;
}
.demo-editor {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
📖 API 文档
Props
参数 | 类型 | 默认值 | 说明 |
---|---|---|---|
modelValue | string | '' | 编辑器内容(双向绑定) |
editorId | string | 'editor-' + timestamp | 编辑器唯一标识 |
placeholder | string | '请输入内容...' | 占位符文本 |
height | number | 300 | 编辑器高度(像素) |
disabled | boolean | false | 是否禁用编辑器 |
readonly | boolean | false | 是否只读模式 |
toolbarConfig | IToolbarConfig | {} | 工具栏配置对象 |
editorConfig | IEditorConfig | {} | 编辑器配置对象 |
mode | 'default' | 'simple' | 'default' | 编辑器模式 |
Events
事件名 | 参数 | 说明 |
---|---|---|
update:modelValue | (html: string) | 内容更新时触发 |
editor-mounted | (editor: IDomEditor) | 编辑器初始化完成时触发 |
editor-change | (html: string) | 编辑器内容变化时触发 |
editor-focus | (editor: IDomEditor) | 编辑器获得焦点时触发 |
editor-blur | (editor: IDomEditor) | 编辑器失去焦点时触发 |
editor-destroyed | () | 编辑器销毁时触发 |
暴露方法
方法名 | 参数 | 返回值 | 说明 |
---|---|---|---|
getEditor | - | IDomEditor | 获取编辑器实例 |
getContent | - | string | 获取编辑器 HTML 内容 |
getText | - | string | 获取编辑器纯文本内容 |
setContent | (html: string) | void | 设置编辑器内容 |
insertContent | (html: string) | void | 在光标位置插入内容 |
focus | - | void | 聚焦编辑器 |
blur | - | void | 失焦编辑器 |
clear | - | void | 清空编辑器内容 |
undo | - | void | 撤销操作 |
redo | - | void | 重做操作 |
类型定义
工具栏配置接口
typescript
interface IToolbarConfig {
excludeKeys?: string[] // 排除的工具栏按钮
insertKeys?: {
// 插入自定义按钮
index: number
keys: string[]
}
modalAppendToBody?: boolean // 弹窗是否挂载到 body
}
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
编辑器配置接口
typescript
interface IEditorConfig {
placeholder?: string // 占位符
readOnly?: boolean // 是否只读
autoFocus?: boolean // 是否自动聚焦
maxLength?: number // 最大字符数限制
MENU_CONF?: {
// 菜单配置
uploadImage?: UploadImageConfig
insertLink?: InsertLinkConfig
// ... 其他菜单配置
}
}
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
上传图片配置
typescript
interface UploadImageConfig {
server: string // 上传接口地址
fieldName?: string // 上传字段名
maxFileSize?: number // 最大文件大小(字节)
maxNumberOfFiles?: number // 最大文件数量
allowedFileTypes?: string[] // 允许的文件类型
onBeforeUpload?: (file: File) => boolean | Promise<boolean>
onProgress?: (progress: number) => void
onSuccess?: (file: File, res: any) => void
onFailed?: (file: File, res: any) => void
onError?: (file: File, err: any) => void
}
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
🎨 使用示例
场景 1: 博客文章编辑器
vue
<template>
<div class="blog-editor">
<n-card title="博客文章编辑">
<!-- 文章信息 -->
<div class="article-meta mb-20px">
<n-space>
<n-input
v-model:value="articleData.title"
placeholder="文章标题"
style="width: 300px"
/>
<n-select
v-model:value="articleData.category"
:options="categoryOptions"
placeholder="选择分类"
style="width: 150px"
/>
<n-tag-input
v-model:value="articleData.tags"
placeholder="添加标签"
style="width: 200px"
/>
</n-space>
</div>
<!-- 富文本编辑器 -->
<C_Editor
ref="blogEditorRef"
v-model="articleData.content"
:height="500"
:editor-config="blogEditorConfig"
:toolbar-config="blogToolbarConfig"
placeholder="开始撰写你的文章..."
@editor-change="handleContentChange"
@editor-mounted="handleEditorMounted"
/>
<!-- 操作按钮 -->
<div class="mt-20px">
<n-space>
<n-button type="primary" @click="saveArticle"> 保存文章 </n-button>
<n-button @click="previewArticle"> 预览 </n-button>
<n-button @click="saveDraft"> 保存草稿 </n-button>
<n-popconfirm @positive-click="clearArticle">
<template #trigger>
<n-button type="error"> 清空内容 </n-button>
</template>
确定要清空所有内容吗?
</n-popconfirm>
</n-space>
</div>
</n-card>
<!-- 文章统计 -->
<n-card class="mt-20px" title="文章统计" size="small">
<n-grid cols="4" x-gap="16">
<n-grid-item>
<n-statistic label="字符数" :value="articleStats.charCount" />
</n-grid-item>
<n-grid-item>
<n-statistic label="段落数" :value="articleStats.paragraphCount" />
</n-grid-item>
<n-grid-item>
<n-statistic label="图片数" :value="articleStats.imageCount" />
</n-grid-item>
<n-grid-item>
<n-statistic
label="预计阅读时间"
:value="articleStats.readTime"
suffix="分钟"
/>
</n-grid-item>
</n-grid>
</n-card>
</div>
</template>
<script setup>
const blogEditorRef = ref()
const message = useMessage()
const articleData = reactive({
title: '',
category: '',
tags: [],
content: '',
status: 'draft',
publishedAt: null,
})
const categoryOptions = [
{ label: '技术分享', value: 'tech' },
{ label: '生活随笔', value: 'life' },
{ label: '产品思考', value: 'product' },
{ label: '团队管理', value: 'management' },
]
// 博客编辑器专用配置
const blogEditorConfig = {
placeholder: '开始撰写你的精彩文章...',
maxLength: 50000,
MENU_CONF: {
uploadImage: {
server: '/api/blog/upload-image',
fieldName: 'image',
maxFileSize: 10 * 1024 * 1024, // 10MB
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif'],
onSuccess: (file, res) => {
message.success('图片上传成功')
},
onFailed: (file, res) => {
message.error('图片上传失败')
},
},
insertLink: {
checkLink: (text, link) => {
if (!link) return '链接不能为空'
if (!link.match(/^https?:\/\//)) return '请输入有效的链接地址'
return true
},
},
},
}
const blogToolbarConfig = {
excludeKeys: [
'group-video', // 博客通常不需要视频
'fullScreen', // 移除全屏按钮
],
}
// 文章统计
const articleStats = computed(() => {
const content = articleData.content
const textContent = content.replace(/<[^>]*>/g, '')
return {
charCount: textContent.length,
paragraphCount: (content.match(/<p>/g) || []).length,
imageCount: (content.match(/<img/g) || []).length,
readTime: Math.ceil(textContent.length / 200), // 按每分钟200字计算
}
})
const handleEditorMounted = (editor) => {
console.log('博客编辑器初始化完成')
// 设置自动保存
setInterval(() => {
if (articleData.content) {
saveDraft()
}
}, 30000) // 每30秒自动保存草稿
}
const handleContentChange = (html) => {
// 内容变化时的处理
if (articleStats.value.charCount > 45000) {
message.warning('文章内容较长,建议分段发布')
}
}
const saveArticle = async () => {
if (!articleData.title.trim()) {
message.error('请输入文章标题')
return
}
try {
articleData.status = 'published'
articleData.publishedAt = new Date()
// 调用保存接口
await api.saveArticle(articleData)
message.success('文章发布成功!')
} catch (error) {
message.error('发布失败,请重试')
}
}
const saveDraft = async () => {
try {
await api.saveDraft(articleData)
message.info('草稿已自动保存')
} catch (error) {
console.error('草稿保存失败:', error)
}
}
const previewArticle = () => {
const previewWindow = window.open('', '_blank')
previewWindow.document.write(`
<html>
<head><title>${articleData.title}</title></head>
<body>
<h1>${articleData.title}</h1>
<div>${articleData.content}</div>
</body>
</html>
`)
}
const clearArticle = () => {
Object.assign(articleData, {
title: '',
category: '',
tags: [],
content: '',
status: 'draft',
publishedAt: null,
})
if (blogEditorRef.value) {
blogEditorRef.value.clear()
}
}
</script>
<style scoped>
.blog-editor {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.article-meta {
border-bottom: 1px solid #f0f0f0;
padding-bottom: 16px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
场景 2: 邮件编辑器
vue
<template>
<div class="email-editor">
<n-card title="撰写邮件">
<!-- 邮件头部信息 -->
<div class="email-header">
<n-space vertical size="large">
<n-input
v-model:value="emailData.to"
placeholder="收件人邮箱,多个邮箱用逗号分隔"
clearable
>
<template #prefix>收件人:</template>
</n-input>
<n-input
v-model:value="emailData.cc"
placeholder="抄送邮箱,多个邮箱用逗号分隔"
clearable
>
<template #prefix>抄 送:</template>
</n-input>
<n-input
v-model:value="emailData.subject"
placeholder="邮件主题"
clearable
>
<template #prefix>主 题:</template>
</n-input>
</n-space>
</div>
<!-- 邮件内容编辑器 -->
<div class="email-content mt-20px">
<C_Editor
ref="emailEditorRef"
v-model="emailData.content"
:height="400"
:editor-config="emailEditorConfig"
:toolbar-config="emailToolbarConfig"
placeholder="请输入邮件内容..."
@editor-mounted="handleEmailEditorMounted"
/>
</div>
<!-- 附件上传 -->
<div class="email-attachments mt-20px">
<n-upload
v-model:file-list="emailData.attachments"
:max="10"
multiple
:show-preview-button="false"
>
<n-button>
<template #icon>
<i class="i-mdi:attachment"></i>
</template>
添加附件
</n-button>
</n-upload>
</div>
<!-- 操作按钮 -->
<div class="email-actions mt-20px">
<n-space>
<n-button type="primary" @click="sendEmail" :loading="sending">
<template #icon>
<i class="i-mdi:send"></i>
</template>
发送邮件
</n-button>
<n-button @click="saveDraft">
<template #icon>
<i class="i-mdi:content-save"></i>
</template>
保存草稿
</n-button>
<n-dropdown :options="templateOptions" @select="insertTemplate">
<n-button>
<template #icon>
<i class="i-mdi:file-document-outline"></i>
</template>
插入模板
</n-button>
</n-dropdown>
<n-button @click="previewEmail">
<template #icon>
<i class="i-mdi:eye"></i>
</template>
预览
</n-button>
</n-space>
</div>
</n-card>
</div>
</template>
<script setup>
const emailEditorRef = ref()
const message = useMessage()
const dialog = useDialog()
const sending = ref(false)
const emailData = reactive({
to: '',
cc: '',
subject: '',
content: '',
attachments: [],
priority: 'normal',
})
// 邮件编辑器配置
const emailEditorConfig = {
placeholder: '请输入邮件内容...',
MENU_CONF: {
uploadImage: {
server: '/api/email/upload-image',
fieldName: 'image',
maxFileSize: 5 * 1024 * 1024, // 5MB
onSuccess: (file, res) => {
message.success('图片插入成功')
},
},
insertLink: {
checkLink: (text, link) => {
if (!link) return '链接不能为空'
return true
},
},
},
}
const emailToolbarConfig = {
excludeKeys: [
'group-video', // 邮件中通常不插入视频
'fullScreen', // 移除全屏
'code', // 移除代码块
'codeSelectLang', // 移除代码语言选择
],
}
// 邮件模板选项
const templateOptions = [
{
label: '商务邮件模板',
key: 'business',
},
{
label: '感谢邮件模板',
key: 'thanks',
},
{
label: '邀请邮件模板',
key: 'invitation',
},
{
label: '通知邮件模板',
key: 'notification',
},
]
const emailTemplates = {
business: `
<p>尊敬的 [收件人姓名]:</p>
<p>您好!</p>
<p>[邮件正文内容]</p>
<p>如有任何问题,请随时与我联系。</p>
<p>此致</p>
<p>敬礼!</p>
<p><br></p>
<p>[您的姓名]</p>
<p>[您的职位]</p>
<p>[公司名称]</p>
<p>[联系方式]</p>
`,
thanks: `
<p>亲爱的 [收件人姓名]:</p>
<p>感谢您的 [具体事项]!</p>
<p>[感谢的具体内容和原因]</p>
<p>再次感谢您的支持与帮助。</p>
<p>祝好!</p>
<p><br></p>
<p>[您的姓名]</p>
`,
invitation: `
<p>尊敬的 [收件人姓名]:</p>
<p>我们诚挚邀请您参加 [活动名称]。</p>
<p><strong>活动详情:</strong></p>
<ul>
<li>时间:[活动时间]</li>
<li>地点:[活动地点]</li>
<li>主题:[活动主题]</li>
</ul>
<p>期待您的参与!</p>
<p>如需确认参加,请回复此邮件。</p>
<p><br></p>
<p>[您的姓名]</p>
<p>[组织名称]</p>
`,
notification: `
<p>各位同事:</p>
<p>现通知如下事项:</p>
<p><strong>[通知标题]</strong></p>
<p>[通知内容详情]</p>
<p><strong>注意事项:</strong></p>
<ul>
<li>[注意事项1]</li>
<li>[注意事项2]</li>
</ul>
<p>如有疑问,请及时联系。</p>
<p><br></p>
<p>[发布人]</p>
<p>[发布时间]</p>
`,
}
const handleEmailEditorMounted = (editor) => {
console.log('邮件编辑器初始化完成')
// 设置邮件签名
const signature = `
<p><br></p>
<hr>
<p><small>
此邮件由系统自动发送,请勿直接回复。<br>
如有问题请联系:support@example.com
</small></p>
`
// 如果内容为空,添加默认签名
if (!emailData.content.trim()) {
editor.setHtml(signature)
}
}
const insertTemplate = (key) => {
const template = emailTemplates[key]
if (template && emailEditorRef.value) {
emailEditorRef.value.setContent(template)
message.success('模板已插入')
}
}
const sendEmail = async () => {
// 验证邮件信息
if (!emailData.to.trim()) {
message.error('请输入收件人邮箱')
return
}
if (!emailData.subject.trim()) {
message.error('请输入邮件主题')
return
}
if (!emailData.content.trim()) {
message.error('请输入邮件内容')
return
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const toEmails = emailData.to.split(',').map((email) => email.trim())
const invalidEmails = toEmails.filter((email) => !emailRegex.test(email))
if (invalidEmails.length > 0) {
message.error(`邮箱格式不正确: ${invalidEmails.join(', ')}`)
return
}
try {
sending.value = true
// 构建邮件数据
const mailData = {
...emailData,
content: emailEditorRef.value.getContent(),
attachments: emailData.attachments.map((file) => ({
name: file.name,
size: file.file?.size,
url: file.url,
})),
}
// 发送邮件
await api.sendEmail(mailData)
message.success('邮件发送成功!')
// 清空表单
Object.assign(emailData, {
to: '',
cc: '',
subject: '',
content: '',
attachments: [],
})
if (emailEditorRef.value) {
emailEditorRef.value.clear()
}
} catch (error) {
message.error('邮件发送失败,请重试')
} finally {
sending.value = false
}
}
const saveDraft = async () => {
try {
await api.saveDraft({
...emailData,
content: emailEditorRef.value?.getContent() || '',
})
message.success('草稿已保存')
} catch (error) {
message.error('草稿保存失败')
}
}
const previewEmail = () => {
if (!emailData.content.trim()) {
message.warning('邮件内容为空')
return
}
const previewContent = `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<div style="border-bottom: 1px solid #eee; padding: 20px 0;">
<p><strong>收件人:</strong> ${emailData.to}</p>
${emailData.cc ? `<p><strong>抄送:</strong> ${emailData.cc}</p>` : ''}
<p><strong>主题:</strong> ${emailData.subject}</p>
</div>
<div style="padding: 20px 0;">
${emailData.content}
</div>
</div>
`
const previewWindow = window.open('', '_blank')
previewWindow.document.write(previewContent)
}
</script>
<style scoped>
.email-editor {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.email-header {
border-bottom: 1px solid #f0f0f0;
padding-bottom: 20px;
}
.email-content {
border-bottom: 1px solid #f0f0f0;
padding-bottom: 20px;
}
.email-attachments {
border-bottom: 1px solid #f0f0f0;
padding-bottom: 20px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
场景 3: 在线文档协作
vue
<template>
<div class="collaborative-editor">
<n-card>
<!-- 文档头部 -->
<template #header>
<div class="doc-header">
<n-space align="center" justify="space-between">
<div>
<n-input
v-model:value="documentData.title"
placeholder="无标题文档"
:bordered="false"
size="large"
style="font-weight: 600;"
@blur="saveDocument"
/>
</div>
<div>
<n-space align="center">
<!-- 在线用户 -->
<n-avatar-group
v-if="onlineUsers.length > 0"
:max="5"
:size="32"
>
<n-tooltip
v-for="user in onlineUsers"
:key="user.id"
:content="user.name"
>
<n-avatar
:src="user.avatar"
:style="{ border: `2px solid ${user.cursorColor}` }"
/>
</n-tooltip>
</n-avatar-group>
<!-- 分享按钮 -->
<n-button type="primary" @click="shareDocument">
<template #icon>
<i class="i-mdi:share-variant"></i>
</template>
分享
</n-button>
<!-- 版本历史 -->
<n-dropdown :options="versionOptions" @select="loadVersion">
<n-button>
<template #icon>
<i class="i-mdi:history"></i>
</template>
版本历史
</n-button>
</n-dropdown>
</n-space>
</div>
</n-space>
</div>
</template>
<!-- 编辑器主体 -->
<div class="collaborative-content">
<C_Editor
ref="collaborativeEditorRef"
v-model="documentData.content"
:height="600"
:editor-config="collaborativeEditorConfig"
:toolbar-config="collaborativeToolbarConfig"
placeholder="开始协作编辑文档..."
@editor-mounted="handleEditorMounted"
@editor-change="handleContentChange"
@editor-focus="handleEditorFocus"
@editor-blur="handleEditorBlur"
/>
</div>
<!-- 底部状态栏 -->
<template #footer>
<div class="doc-footer">
<n-space align="center" justify="space-between">
<div>
<n-space align="center">
<n-tag :type="saveStatus.type" size="small">
<template #icon>
<i :class="saveStatus.icon"></i>
</template>
{{ saveStatus.text }}
</n-tag>
<span class="text-sm text-gray-500">
{{ documentStats.words }} 字 |
{{ documentStats.characters }} 字符
</span>
</n-space>
</div>
<div>
<n-space align="center">
<span class="text-sm text-gray-500">
最后编辑: {{ lastEditedBy }}
</span>
<!-- 评论按钮 -->
<n-badge :value="commentsCount" :max="99">
<n-button size="small" @click="toggleComments">
<template #icon>
<i class="i-mdi:comment-outline"></i>
</template>
评论
</n-button>
</n-badge>
</n-space>
</div>
</n-space>
</div>
</template>
</n-card>
<!-- 评论侧边栏 -->
<n-drawer
v-model:show="showComments"
:width="360"
placement="right"
title="评论"
>
<div class="comments-panel">
<div v-if="comments.length === 0" class="empty-comments">
<n-empty description="暂无评论" />
</div>
<div v-else class="comments-list">
<div
v-for="comment in comments"
:key="comment.id"
class="comment-item"
>
<n-space>
<n-avatar :src="comment.user.avatar" size="small" />
<div class="comment-content">
<div class="comment-header">
<span class="comment-author">{{ comment.user.name }}</span>
<span class="comment-time">{{
formatTime(comment.createdAt)
}}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
</div>
</n-space>
</div>
</div>
<!-- 添加评论 -->
<div class="add-comment">
<n-input
v-model:value="newComment"
type="textarea"
placeholder="添加评论..."
:autosize="{ minRows: 2, maxRows: 4 }"
/>
<n-button
type="primary"
size="small"
class="mt-8px"
@click="addComment"
:disabled="!newComment.trim()"
>
添加评论
</n-button>
</div>
</div>
</n-drawer>
</div>
</template>
<script setup>
const collaborativeEditorRef = ref()
const message = useMessage()
// WebSocket连接(模拟协作)
let ws = null
const documentData = reactive({
id: 'doc-' + Date.now(),
title: '协作文档示例',
content: `
<h1>团队协作文档</h1>
<p>这是一个支持实时协作的文档编辑器。多个用户可以同时编辑,并看到其他人的修改。</p>
<h2>功能特性</h2>
<ul>
<li>实时协作编辑</li>
<li>用户光标显示</li>
<li>版本历史管理</li>
<li>评论系统</li>
<li>自动保存</li>
</ul>
`,
version: 1,
lastModified: new Date(),
})
// 在线用户
const onlineUsers = ref([
{
id: '1',
name: '张三',
avatar: '/avatars/user1.jpg',
cursorColor: '#3f86ff',
},
{
id: '2',
name: '李四',
avatar: '/avatars/user2.jpg',
cursorColor: '#67c23a',
},
])
// 保存状态
const saveStatus = reactive({
type: 'success',
icon: 'i-mdi:check-circle',
text: '已保存',
})
// 评论系统
const showComments = ref(false)
const newComment = ref('')
const comments = ref([
{
id: '1',
content: '这个想法很不错!',
user: {
name: '王五',
avatar: '/avatars/user3.jpg',
},
createdAt: new Date(Date.now() - 2 * 3600000),
},
])
const commentsCount = computed(() => comments.value.length)
// 文档统计
const documentStats = computed(() => {
const content = documentData.content.replace(/<[^>]*>/g, '')
return {
words: content.split(/\s+/).filter((word) => word.length > 0).length,
characters: content.length,
}
})
const lastEditedBy = computed(() => {
return `${onlineUsers.value[0]?.name || '未知用户'} ${formatTime(
documentData.lastModified
)}`
})
// 版本历史
const versionOptions = [
{ label: '版本 3 - 2小时前', key: '3' },
{ label: '版本 2 - 1天前', key: '2' },
{ label: '版本 1 - 3天前', key: '1' },
]
// 协作编辑器配置
const collaborativeEditorConfig = {
placeholder: '开始协作编辑文档...',
MENU_CONF: {
uploadImage: {
server: '/api/docs/upload-image',
fieldName: 'image',
maxFileSize: 10 * 1024 * 1024,
onSuccess: (file, res) => {
message.success('图片上传成功')
broadcastChange('image_uploaded', { url: res.data.url })
},
},
},
}
const collaborativeToolbarConfig = {
insertKeys: {
index: 0,
keys: ['comment', 'version-history'],
},
}
const handleEditorMounted = (editor) => {
console.log('协作编辑器初始化完成')
// 初始化WebSocket连接
initWebSocket()
// 设置自动保存
setInterval(() => {
if (documentData.content) {
autoSave()
}
}, 10000) // 每10秒自动保存
}
const handleContentChange = (html) => {
documentData.lastModified = new Date()
// 更新保存状态
saveStatus.type = 'warning'
saveStatus.icon = 'i-mdi:pencil'
saveStatus.text = '编辑中...'
// 广播变更给其他用户
broadcastChange('content_change', {
content: html,
cursor: getCurrentCursorPosition(),
})
// 防抖保存
clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
autoSave()
}, 2000)
}
let saveTimeout = null
const handleEditorFocus = () => {
broadcastChange('user_focus', {
userId: getCurrentUserId(),
timestamp: Date.now(),
})
}
const handleEditorBlur = () => {
broadcastChange('user_blur', {
userId: getCurrentUserId(),
timestamp: Date.now(),
})
}
// WebSocket相关方法
const initWebSocket = () => {
try {
ws = new WebSocket(`ws://localhost:8080/collaborate/${documentData.id}`)
ws.onopen = () => {
console.log('协作连接已建立')
message.success('已连接到协作服务器')
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
handleCollaborativeMessage(data)
}
ws.onclose = () => {
console.log('协作连接已断开')
message.warning('协作连接已断开,尝试重连...')
// 重连逻辑
setTimeout(initWebSocket, 3000)
}
ws.onerror = (error) => {
console.error('协作连接错误:', error)
message.error('协作连接出错')
}
} catch (error) {
console.log('WebSocket连接失败,使用模拟协作模式')
}
}
const broadcastChange = (type, data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type,
data,
userId: getCurrentUserId(),
timestamp: Date.now(),
})
)
}
}
const handleCollaborativeMessage = (message) => {
const { type, data, userId } = message
// 忽略自己的消息
if (userId === getCurrentUserId()) return
switch (type) {
case 'content_change':
// 处理其他用户的内容变更
handleRemoteContentChange(data)
break
case 'user_join':
// 用户加入
handleUserJoin(data)
break
case 'user_leave':
// 用户离开
handleUserLeave(data)
break
case 'cursor_change':
// 光标位置变更
handleCursorChange(data)
break
}
}
const getCurrentUserId = () => {
return 'current-user-id' // 实际应用中从认证信息获取
}
const getCurrentCursorPosition = () => {
// 获取当前光标位置的逻辑
return { line: 1, column: 1 }
}
// 保存相关方法
const autoSave = async () => {
try {
await api.saveDocument({
id: documentData.id,
title: documentData.title,
content: collaborativeEditorRef.value?.getContent() || '',
version: documentData.version + 1,
})
saveStatus.type = 'success'
saveStatus.icon = 'i-mdi:check-circle'
saveStatus.text = '已保存'
documentData.version++
} catch (error) {
saveStatus.type = 'error'
saveStatus.icon = 'i-mdi:alert-circle'
saveStatus.text = '保存失败'
}
}
const saveDocument = () => {
autoSave()
}
// 分享相关方法
const shareDocument = () => {
const shareUrl = `${window.location.origin}/docs/${documentData.id}`
navigator.clipboard
.writeText(shareUrl)
.then(() => {
message.success('分享链接已复制到剪贴板')
})
.catch(() => {
// 降级方案
prompt('分享链接(请手动复制):', shareUrl)
})
}
// 版本历史
const loadVersion = (versionKey) => {
message.info(`正在加载版本 ${versionKey}...`)
// 实际应用中从服务器加载对应版本
}
// 评论相关方法
const toggleComments = () => {
showComments.value = !showComments.value
}
const addComment = () => {
if (!newComment.value.trim()) return
const comment = {
id: Date.now().toString(),
content: newComment.value,
user: {
name: '当前用户',
avatar: '/avatars/current-user.jpg',
},
createdAt: new Date(),
}
comments.value.push(comment)
newComment.value = ''
message.success('评论已添加')
}
const formatTime = (date) => {
return new Date(date).toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// 清理资源
onUnmounted(() => {
if (ws) {
ws.close()
}
if (saveTimeout) {
clearTimeout(saveTimeout)
}
})
</script>
<style scoped>
.collaborative-editor {
height: 100vh;
padding: 16px;
}
.doc-header {
width: 100%;
}
.doc-footer {
width: 100%;
padding: 8px 0;
border-top: 1px solid #f0f0f0;
}
.comments-panel {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
}
.comments-list {
flex: 1;
overflow-y: auto;
}
.comment-item {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.comment-content {
flex: 1;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.comment-author {
font-weight: 600;
font-size: 12px;
}
.comment-time {
font-size: 11px;
color: #666;
}
.comment-text {
font-size: 13px;
line-height: 1.4;
}
.add-comment {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.empty-comments {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
🛠️ 高级用法
自定义工具栏
vue
<template>
<C_Editor
ref="customEditorRef"
v-model="content"
:toolbar-config="customToolbarConfig"
@editor-mounted="handleEditorMounted"
/>
</template>
<script setup>
const customEditorRef = ref()
const content = ref('')
// 自定义工具栏配置
const customToolbarConfig = {
// 排除不需要的按钮
excludeKeys: ['group-video', 'fullScreen', 'emotion'],
// 插入自定义按钮
insertKeys: {
index: 0, // 插入位置
keys: ['customButton1', 'customButton2'],
},
// 工具栏样式
modalAppendToBody: true,
}
const handleEditorMounted = (editor) => {
// 注册自定义按钮
const { Boot } = window.wangEditor
// 自定义按钮1:插入当前时间
class InsertTimeButton {
constructor() {
this.title = '插入时间'
this.iconSvg = '<svg>...</svg>' // 自定义图标SVG
this.tag = 'button'
}
getValue() {
return ''
}
isActive() {
return false
}
isDisabled() {
return false
}
exec() {
const currentTime = new Date().toLocaleString()
editor.insertText(`[${currentTime}]`)
}
}
// 注册自定义按钮
Boot.registerMenu({
key: 'customButton1',
factory() {
return new InsertTimeButton()
},
})
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
内容过滤和验证
vue
<template>
<C_Editor
ref="filterEditorRef"
v-model="content"
:editor-config="filterEditorConfig"
@editor-change="handleContentChange"
/>
</template>
<script setup>
const filterEditorRef = ref()
const content = ref('')
// 内容过滤配置
const filterEditorConfig = {
// 自定义HTML过滤
MENU_CONF: {
// 配置允许的HTML标签和属性
allowedTags: ['p', 'h1', 'h2', 'h3', 'strong', 'em', 'u', 'ol', 'ul', 'li'],
allowedAttributes: {
a: ['href', 'title'],
img: ['src', 'alt', 'width', 'height'],
},
// 内容处理器
parseElemsHtml: [
// 过滤危险脚本
(elemHtml, elem) => {
if (elemHtml.includes('<script')) {
return ''
}
return elemHtml
},
// 处理外部链接
(elemHtml, elem) => {
if (elem.tagName === 'A') {
return elemHtml.replace(/<a /g, '<a target="_blank" rel="noopener" ')
}
return elemHtml
},
],
},
}
const handleContentChange = (html) => {
// 实时内容验证
validateContent(html)
}
const validateContent = (html) => {
const rules = [
{
name: '字符长度',
check: (content) => content.replace(/<[^>]*>/g, '').length <= 10000,
message: '内容长度不能超过10000字符',
},
{
name: '图片数量',
check: (content) => (content.match(/<img/g) || []).length <= 10,
message: '图片数量不能超过10张',
},
{
name: '链接数量',
check: (content) => (content.match(/<a/g) || []).length <= 20,
message: '链接数量不能超过20个',
},
]
for (const rule of rules) {
if (!rule.check(html)) {
message.warning(rule.message)
break
}
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
多编辑器实例管理
vue
<template>
<div class="multi-editor-manager">
<n-tabs
v-model:value="activeTab"
type="editable-card"
@add="addEditor"
@close="closeEditor"
>
<n-tab-pane
v-for="editor in editors"
:key="editor.id"
:name="editor.id"
:tab="editor.title"
:closable="editors.length > 1"
>
<C_Editor
:ref="(el) => setEditorRef(editor.id, el)"
v-model="editor.content"
:editor-id="editor.id"
:height="400"
@editor-mounted="
(editorInstance) => handleEditorMounted(editor.id, editorInstance)
"
@editor-change="(html) => handleEditorChange(editor.id, html)"
/>
</n-tab-pane>
</n-tabs>
</div>
</template>
<script setup>
const activeTab = ref('editor-1')
const editorRefs = new Map()
const editors = ref([
{
id: 'editor-1',
title: '文档1',
content: '<p>第一个编辑器的内容</p>',
},
])
const setEditorRef = (id, ref) => {
if (ref) {
editorRefs.set(id, ref)
} else {
editorRefs.delete(id)
}
}
const handleEditorMounted = (editorId, editorInstance) => {
console.log(`编辑器 ${editorId} 已挂载:`, editorInstance)
}
const handleEditorChange = (editorId, html) => {
const editor = editors.value.find((e) => e.id === editorId)
if (editor) {
editor.content = html
}
}
const addEditor = () => {
const newId = `editor-${Date.now()}`
const newEditor = {
id: newId,
title: `文档${editors.value.length + 1}`,
content: '<p>新建文档的内容</p>',
}
editors.value.push(newEditor)
activeTab.value = newId
}
const closeEditor = (editorId) => {
const index = editors.value.findIndex((e) => e.id === editorId)
if (index !== -1) {
editors.value.splice(index, 1)
editorRefs.delete(editorId)
// 切换到其他标签
if (activeTab.value === editorId && editors.value.length > 0) {
activeTab.value = editors.value[0].id
}
}
}
// 提供全局方法
const getAllContents = () => {
const contents = {}
editors.value.forEach((editor) => {
const editorRef = editorRefs.get(editor.id)
if (editorRef) {
contents[editor.id] = editorRef.getContent()
}
})
return contents
}
const saveAllEditors = async () => {
const contents = getAllContents()
try {
await api.saveMultipleDocuments(contents)
message.success('所有文档已保存')
} catch (error) {
message.error('保存失败')
}
}
defineExpose({
getAllContents,
saveAllEditors,
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
⚠️ 注意事项
1. 编辑器初始化
vue
<!-- ✅ 推荐:等待编辑器挂载后再进行操作 -->
<script setup>
const editorRef = ref()
const isEditorReady = ref(false)
const handleEditorMounted = (editor) => {
isEditorReady.value = true
// 现在可以安全地调用编辑器方法
editor.setHtml('<p>初始内容</p>')
}
const setContent = () => {
if (isEditorReady.value && editorRef.value) {
editorRef.value.setContent('<p>新内容</p>')
}
}
</script>
<!-- ❌ 不推荐:在编辑器未初始化时调用方法 -->
<script setup>
const editorRef = ref()
const setContent = () => {
// 可能会失败,因为编辑器可能还未初始化
editorRef.value.setContent('<p>新内容</p>')
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2. 内容格式处理
javascript
// ✅ 推荐:检查内容格式
const setEditorContent = (content) => {
// 确保内容是字符串
if (typeof content !== 'string') {
content = String(content)
}
// 检查是否为有效HTML
if (!content.includes('<')) {
content = `<p>${content}</p>`
}
editorRef.value.setContent(content)
}
// ❌ 不推荐:直接设置可能有问题的内容
const setEditorContent = (content) => {
editorRef.value.setContent(content) // 可能导致格式问题
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
3. 内存管理
vue
<script setup>
const editorRef = ref()
// ✅ 推荐:组件销毁时清理资源
onUnmounted(() => {
if (editorRef.value) {
const editor = editorRef.value.getEditor()
if (editor) {
editor.destroy()
}
}
})
// 清理定时器
let autoSaveTimer = null
const startAutoSave = () => {
autoSaveTimer = setInterval(() => {
saveContent()
}, 30000)
}
onUnmounted(() => {
if (autoSaveTimer) {
clearInterval(autoSaveTimer)
}
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
4. 图片上传配置
javascript
// ✅ 推荐:完整的图片上传配置
const editorConfig = {
MENU_CONF: {
uploadImage: {
server: '/api/upload-image',
fieldName: 'file',
maxFileSize: 5 * 1024 * 1024, // 5MB
maxNumberOfFiles: 10,
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif'],
// 上传前检查
onBeforeUpload: (file) => {
const isValidSize = file.size <= 5 * 1024 * 1024
const isValidType = ['image/jpeg', 'image/png', 'image/gif'].includes(
file.type
)
if (!isValidSize) {
message.error('图片大小不能超过5MB')
return false
}
if (!isValidType) {
message.error('只支持JPG、PNG、GIF格式的图片')
return false
}
return true
},
// 成功回调
onSuccess: (file, res) => {
message.success('图片上传成功')
},
// 失败回调
onFailed: (file, res) => {
message.error('图片上传失败')
},
// 错误回调
onError: (file, err) => {
message.error('图片上传出错')
},
},
},
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
🐛 故障排除
常见问题
Q1: 编辑器无法正常显示?
A1: 检查以下几点:
javascript
// 1. 确保正确导入了CSS样式
import '@wangeditor/editor/dist/css/style.css'
// 2. 检查容器高度设置
const editorConfig = {
height: 400, // 确保设置了高度
}
// 3. 检查容器的CSS样式
.editor-container {
min-height: 300px; /* 确保容器有足够高度 */
}
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
Q2: v-model 双向绑定不生效?
A2: 检查数据绑定:
vue
<!-- ✅ 正确的绑定方式 -->
<C_Editor v-model="content" @editor-change="handleChange" />
<script setup>
const content = ref('') // 确保是响应式数据
const handleChange = (html) => {
console.log('内容变化:', html)
// content 会自动更新
}
</script>
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
Q3: 自定义配置不生效?
A3: 检查配置格式:
javascript
// ✅ 正确的配置格式
const editorConfig = {
MENU_CONF: {
uploadImage: {
server: '/api/upload',
// 其他配置...
},
},
}
// ❌ 错误的配置格式
const editorConfig = {
uploadImage: {
// 缺少 MENU_CONF 包装
server: '/api/upload',
},
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Q4: 编辑器内容无法保存?
A4: 检查内容获取方式:
javascript
// ✅ 推荐的内容获取方式
const saveContent = () => {
if (editorRef.value) {
const content = editorRef.value.getContent()
// 保存到服务器
api.saveContent(content)
}
}
// ✅ 或者使用v-model绑定的值
const saveContent = () => {
api.saveContent(content.value)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
Q5: 禁用/只读模式不工作?
A5: 检查模式设置:
vue
<C_Editor
v-model="content"
:disabled="isDisabled" <!-- 禁用模式 -->
:readonly="isReadonly" <!-- 只读模式 -->
/>
<script setup>
const isDisabled = ref(false)
const isReadonly = ref(false)
// 动态切换模式
const toggleMode = () => {
isDisabled.value = !isDisabled.value
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
🎯 最佳实践
1. 组件封装
javascript
// 创建可复用的编辑器组件
// components/ArticleEditor.vue
<template>
<div class="article-editor">
<C_Editor
ref="editorRef"
v-model="localContent"
:editor-config="articleEditorConfig"
:toolbar-config="articleToolbarConfig"
:height="height"
@editor-mounted="handleEditorMounted"
@editor-change="handleContentChange"
/>
<div class="editor-footer">
<div class="word-count">
字数: {{ wordCount }}
</div>
<div class="auto-save-status">
<n-tag :type="saveStatus.type">{{ saveStatus.text }}</n-tag>
</div>
</div>
</div>
</template>
<script setup>
interface Props {
modelValue: string
height?: number
autoSave?: boolean
maxLength?: number
}
const props = withDefaults(defineProps<Props>(), {
height: 400,
autoSave: true,
maxLength: 50000,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'save': [content: string]
'change': [content: string]
}>()
const editorRef = ref()
const localContent = ref(props.modelValue)
const saveStatus = reactive({
type: 'success' as const,
text: '已保存',
})
// 配置文章编辑器
const articleEditorConfig = computed(() => ({
placeholder: '开始撰写你的文章...',
maxLength: props.maxLength,
MENU_CONF: {
uploadImage: {
server: '/api/article/upload-image',
fieldName: 'image',
maxFileSize: 10 * 1024 * 1024,
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif'],
},
},
}))
const wordCount = computed(() => {
return localContent.value.replace(/<[^>]*>/g, '').length
})
// 监听内容变化
watch(
() => props.modelValue,
(newVal) => {
if (newVal !== localContent.value) {
localContent.value = newVal
}
}
)
watch(
localContent,
(newVal) => {
emit('update:modelValue', newVal)
emit('change', newVal)
if (props.autoSave) {
debouncedSave()
}
}
)
// 防抖保存
const debouncedSave = debounce(() => {
emit('save', localContent.value)
saveStatus.type = 'success'
saveStatus.text = '已保存'
}, 2000)
const handleContentChange = (html: string) => {
saveStatus.type = 'warning'
saveStatus.text = '编辑中...'
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
2. 错误处理和重试机制
javascript
// 带错误处理的编辑器操作
class EditorOperationManager {
constructor(editorRef) {
this.editorRef = editorRef
this.retryCount = 0
this.maxRetries = 3
}
async setContent(content, retryCount = 0) {
try {
if (!this.editorRef.value) {
throw new Error('编辑器未初始化')
}
this.editorRef.value.setContent(content)
this.retryCount = 0 // 重置重试次数
return true
} catch (error) {
console.error('设置内容失败:', error)
if (retryCount < this.maxRetries) {
console.log(`重试设置内容 (${retryCount + 1}/${this.maxRetries})`)
// 等待一段时间后重试
await new Promise((resolve) => setTimeout(resolve, 1000))
return this.setContent(content, retryCount + 1)
}
throw new Error(`设置内容失败,已重试${this.maxRetries}次`)
}
}
async uploadImage(file) {
const maxRetries = 3
let lastError
for (let i = 0; i < maxRetries; i++) {
try {
const result = await api.uploadImage(file)
return result
} catch (error) {
lastError = error
console.warn(`图片上传失败 (${i + 1}/${maxRetries}):`, error)
if (i < maxRetries - 1) {
// 指数退避
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, i) * 1000)
)
}
}
}
throw lastError
}
}
// 使用示例
const editorManager = new EditorOperationManager(editorRef)
const handleSetContent = async (content) => {
try {
await editorManager.setContent(content)
message.success('内容设置成功')
} catch (error) {
message.error(error.message)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
3. 性能优化
javascript
// 优化大文档编辑性能
const useEditorPerformance = (editorRef) => {
const isLargeDocument = ref(false)
const performanceMode = ref(false)
// 监控文档大小
const checkDocumentSize = (content) => {
const size = content.length
const isLarge = size > 100000 // 100KB
if (isLarge !== isLargeDocument.value) {
isLargeDocument.value = isLarge
if (isLarge && !performanceMode.value) {
enablePerformanceMode()
} else if (!isLarge && performanceMode.value) {
disablePerformanceMode()
}
}
}
const enablePerformanceMode = () => {
performanceMode.value = true
// 减少不必要的工具栏功能
if (editorRef.value) {
const editor = editorRef.value.getEditor()
// 禁用一些消耗性能的功能
editor.config.placeholder = '大文档模式 - 某些功能已优化'
}
message.info('已启用性能优化模式')
}
const disablePerformanceMode = () => {
performanceMode.value = false
message.info('已关闭性能优化模式')
}
return {
isLargeDocument,
performanceMode,
checkDocumentSize,
}
}
// 防抖和节流优化
const useOptimizedEditor = () => {
// 防抖的内容保存
const debouncedSave = debounce(async (content) => {
try {
await api.saveContent(content)
} catch (error) {
console.error('保存失败:', error)
}
}, 2000)
// 节流的字数统计
const throttledWordCount = throttle((content) => {
const words = content.replace(/<[^>]*>/g, '').length
updateWordCount(words)
}, 500)
return {
debouncedSave,
throttledWordCount,
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
4. 内容验证和安全
javascript
// 内容安全验证
const useContentSecurity = () => {
const sanitizeContent = (html) => {
// 移除潜在危险的标签和属性
const dangerousTags = ['script', 'iframe', 'object', 'embed', 'form']
const dangerousAttrs = ['onclick', 'onload', 'onerror', 'javascript:']
let sanitized = html
// 移除危险标签
dangerousTags.forEach((tag) => {
const regex = new RegExp(`<${tag}[^>]*>.*?</${tag}>`, 'gi')
sanitized = sanitized.replace(regex, '')
})
// 移除危险属性
dangerousAttrs.forEach((attr) => {
const regex = new RegExp(`${attr}[^>]*`, 'gi')
sanitized = sanitized.replace(regex, '')
})
return sanitized
}
const validateContent = (content) => {
const errors = []
// 检查长度
if (content.length > 500000) {
errors.push('内容长度超出限制')
}
// 检查图片数量
const imageCount = (content.match(/<img/g) || []).length
if (imageCount > 50) {
errors.push('图片数量过多')
}
// 检查链接数量
const linkCount = (content.match(/<a/g) || []).length
if (linkCount > 100) {
errors.push('链接数量过多')
}
return {
isValid: errors.length === 0,
errors,
}
}
return {
sanitizeContent,
validateContent,
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
📝 更新日志
v1.0.0 (2025-07-19)
- ✨ 基于 WangEditor 的完整 Vue 3 组件封装
- ✨ 支持所见即所得富文本编辑
- ✨ 完整的双向数据绑定支持
- ✨ 支持禁用和只读模式
- ✨ 自定义工具栏配置
- ✨ 图片上传和媒体插入功能
- ✨ 内置内容过滤和安全防护
- ✨ 完整的 TypeScript 类型定义
- ✨ 响应式设计和移动端适配
- ✨ 丰富的生命周期事件回调
- ✨ 支持自定义主题和样式
- ✨ 内置性能优化机制
🤝 贡献指南
- Fork 项目
- 创建功能分支 (
git checkout -b feature/amazing-feature
) - 提交更改 (
git commit -m 'Add amazing feature'
) - 推送到分支 (
git push origin feature/amazing-feature
) - 创建 Pull Request
📄 许可证
Copyright (c) 2025 by ChenYu, All Rights Reserved.
💡 提示: 这个富文本编辑器组件基于强大的 WangEditor 构建,提供了完整的所见即所得编辑体验和丰富的功能扩展。支持图片上传、链接插入、表格编辑等常用功能,同时具备良好的安全性和性能表现。无论是博客编辑、邮件撰写还是文档协作,都能提供专业级的编辑体验。结合 TypeScript 支持和响应式设计,让富文本编辑既强大又易用。如果遇到问题请先查看文档,或者在团队群里讨论。让我们一起打造更高效的内容创作体验! 📝