Files
guilixu-admin/src/components/RichTextEditor.vue
T
gxwebsoft cf7d065379 feat(shop): 商品管理编辑弹窗替换为WangEditor富文本编辑器
- 用户反馈编辑弹窗的Markdown编辑器体验差,采用website-admin的富文本编辑器替代
- 新增依赖@wangeditor/editor、@wangeditor/editor-for-vue、markdown-it及类型声明
- 新建RichTextEditor组件,去除Nuxt专用包装,适配现有文件上传接口
- shopGoodsEdit.vue移除MdEditor及相关冗余代码,改用RichTextEditor显示内容
- 实现编辑时Markdown内容转换为HTML,保持数据库存储格式新旧兼容
- 保存逻辑保持不变,提交内容为HTML格式
- 确认小程序端可兼容新旧内容格式,潜在HTML图片链接误解析风险
- build验证通过,文件产出正常,图片上传小程序域名白名单需上线后确认
2026-08-11 18:52:40 +08:00

206 lines
5.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="rich-text-editor">
<!-- 编辑器尚未初始化时的占位 -->
<div
v-if="!editorReady"
:style="{ height: editorHeight + 'px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }"
>
编辑器加载中...
</div>
<template v-else>
<component
:is="Toolbar"
v-if="editorRef"
:editor="editorRef"
:defaultConfig="toolbarConfig"
style="border-bottom: 1px solid #d9d9d9"
/>
<component
:is="Editor"
:defaultConfig="editorConfig"
:modelValue="modelValue"
@onCreated="handleCreated"
@onChange="handleChange"
:style="{ height: editorHeight + 'px', overflowY: 'hidden' }"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
import { message } from 'ant-design-vue'
const editorReady = ref(false)
const editorRef = shallowRef<any>()
const Toolbar = shallowRef<any>(null)
const Editor = shallowRef<any>(null)
const props = withDefaults(
defineProps<{
modelValue?: string
placeholder?: string
/** 编辑器高度(px),默认 380 */
height?: number
/** 图片上传大小限制(MB),默认 5 */
maxImageSize?: number
}>(),
{
modelValue: '',
placeholder: '请输入正文...',
height: 380,
maxImageSize: 5
}
)
const editorHeight = computed(() => props.height)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// 工具栏配置
const toolbarConfig: Record<string, any> = {
// 视频上传未配置,仍排除;全屏按钮保留(更自然)
excludeKeys: ['group-video']
}
/** 上传图片并返回 URL */
async function doUploadImage(file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片文件')
return ''
}
if (file.size / 1024 / 1024 > props.maxImageSize) {
message.error(`图片大小不能超过 ${props.maxImageSize}MB`)
return ''
}
// 动态导入 uploadFile 避免循环依赖
const { uploadFile } = await import('@/api/system/file')
const res = await uploadFile(file)
return res.url || res.path || ''
}
/** 在编辑器当前光标处插入图片 */
function insertEditorImage(editor: any, url: string, alt = '') {
if (!editor || !url) return
try {
if (typeof editor.focus === 'function') editor.focus()
if (typeof editor.insertNode === 'function') {
editor.insertNode({
type: 'image',
src: url,
alt,
href: url,
children: [{ text: '' }]
})
} else if (typeof editor.dangerouslyInsertHtml === 'function') {
editor.dangerouslyInsertHtml(`<img src="${url}" alt="${alt}" style="max-width:100%;"/>`)
}
} catch (e) {
message.error('图片插入失败')
}
}
// 编辑器配置
const editorConfig: Record<string, any> = {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
try {
const url = await doUploadImage(file)
if (url) insertFn(url, file.name || 'image', url)
} catch (e: any) {
message.error(e?.message || '图片上传失败')
}
}
}
},
customPaste(editor: any, event: ClipboardEvent) {
const items = event.clipboardData?.items
if (!items || items.length === 0) return true
const imageFiles: File[] = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item) continue
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) imageFiles.push(file)
}
}
if (imageFiles.length === 0) return true
// 阻止默认粘贴,避免把 base64 大图片直接塞进编辑器
event.preventDefault()
// 异步上传所有粘贴的图片并插入
Promise.all(
imageFiles.map(async (file) => {
const url = await doUploadImage(file)
return { file, url }
})
).then((results) => {
results.forEach(({ url }) => {
if (url) insertEditorImage(editor, url, '粘贴图片')
})
})
return false
}
}
function handleCreated(editor: any) {
editorRef.value = editor
}
function handleChange(editor: any) {
emit('update:modelValue', editor.getHtml())
}
/** 在光标处插入 HTML(供「插入附件」等外部入口调用) */
function insertHtml(html: string) {
const editor = editorRef.value
if (editor && typeof editor.dangerouslyInsertHtml === 'function') {
editor.dangerouslyInsertHtml(html)
}
}
defineExpose({ insertHtml })
// 组件销毁时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor && typeof editor.destroy === 'function') {
try {
editor.destroy()
} catch {
// 已销毁或实例无效时忽略
}
}
editorRef.value = null
})
// 客户端才加载 WangEditorCSS + 组件)
onMounted(async () => {
// 并行加载 CSS 和组件
const [cssModule, wangEditorModule] = await Promise.all([
import('@wangeditor/editor/dist/css/style.css'),
import('@wangeditor/editor-for-vue')
])
Toolbar.value = wangEditorModule.Toolbar
Editor.value = wangEditorModule.Editor
editorReady.value = true
})
</script>
<style scoped>
.rich-text-editor {
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
}
</style>