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验证通过,文件产出正常,图片上传小程序域名白名单需上线后确认
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
<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
|
||||
})
|
||||
|
||||
// 客户端才加载 WangEditor(CSS + 组件)
|
||||
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>
|
||||
Reference in New Issue
Block a user