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:
2026-08-11 18:52:40 +08:00
parent b6ae3d71ef
commit cf7d065379
5 changed files with 658 additions and 160 deletions
+205
View File
@@ -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
})
// 客户端才加载 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>
@@ -106,11 +106,11 @@
v-model:value="form.buyingPrice"
/>
</a-form-item>
<a-form-item :label="form.canExpress === 1 ? '运费模板(必填)' : '运费模板'" v-if="!merchantId" :required="form.canExpress === 1">
<a-form-item :label="form.canExpress === 1 ? '运费模板' : '运费模板'" v-if="!merchantId" :required="form.canExpress === 1">
<a-select
v-model:value="form.expressTemplateId"
style="width: 240px"
:placeholder="form.canExpress === 1 ? '请选择运费模板(必选)' : '请选择运费模板'"
:placeholder="form.canExpress === 1 ? '请选择运费模板' : '请选择运费模板'"
>
<a-select-option
v-for="(item, index) in expressTemplateList"
@@ -212,18 +212,12 @@
</a-form-item>
</a-tab-pane>
<a-tab-pane tab="商品详情" key="content">
<!-- Markdown 编辑器 -->
<MdEditor
<RichTextEditor
v-if="active === 'content'"
v-model="content"
:disabled="disabled"
height="650px"
placeholder="支持 Markdown 语法,可直接粘贴或拖拽图片..."
:toolbars="markdownToolbars"
:onUploadImg="onMarkdownUploadImg"
:height="520"
placeholder="请输入商品详情,支持图文混排..."
/>
<div class="file-selector-tip">
💡 提示支持 Markdown 语法可直接拖拽上传图片也可以使用工具栏按钮从文件库选择图片
</div>
</a-tab-pane>
<a-tab-pane tab="商品规格" key="spec">
<a-form-item label="规格类型" name="specs">
@@ -674,23 +668,6 @@
</a-tabs>
</a-form>
</a-drawer>
<!-- 文件库选择弹窗 -->
<SelectData
v-model:visible="showFileSelector"
title="选择图片"
type="image"
class="file-selector-modal"
@done="onFileSelected"
/>
<!-- 视频库选择弹窗 -->
<SelectData
v-model:visible="showVideoSelector"
title="选择视频"
type="video"
class="file-selector-modal"
@done="onVideoSelected"
/>
</template>
@@ -703,7 +680,7 @@ import {
SettingOutlined,
UploadOutlined
} from '@ant-design/icons-vue';
import {ref, reactive, watch, nextTick} from 'vue';
import {ref, reactive, watch} from 'vue';
import {useRouter} from 'vue-router';
import {Form, message} from 'ant-design-vue';
import {assignObject, messageLoading, uuid} from 'ele-admin-pro';
@@ -713,12 +690,11 @@ import {useThemeStore} from '@/store/modules/theme';
import {storeToRefs} from 'pinia';
import {ItemType} from 'ele-admin-pro/es/ele-image-upload/types';
import {FormInstance, RuleObject} from 'ant-design-vue/es/form';
import {FileRecord} from '@/api/system/file/model';
import {ShopMerchant} from '@/api/shop/shopMerchant/model';
import {uploadFile, uploadOss} from '@/api/system/file';
import { MdEditor } from 'md-editor-v3';
import 'md-editor-v3/lib/style.css';
import 'md-editor-v3/lib/preview.css';
import {FileRecord} from '@/api/system/file/model';
import RichTextEditor from '@/components/RichTextEditor.vue';
import markdownit from 'markdown-it';
import {ShopSpec} from '@/api/shop/shopSpec/model';
import {ShopGoodsSku} from '@/api/shop/shopGoodsSku/model';
import {ShopGoodsSpec} from '@/api/shop/shopGoodsSpec/model';
@@ -731,7 +707,6 @@ import {ShopExpressTemplate} from '@/api/shop/shopExpressTemplate/model';
import {listShopExpressTemplate} from '@/api/shop/shopExpressTemplate';
import {listShopCommissionRole} from '@/api/shop/shopCommissionRole';
import {listShopGoodsRoleCommission} from '@/api/shop/shopGoodsRoleCommission';
import SelectData from "@/components/SelectFile/components/select-data.vue";
// 是否是修改
const isUpdate = ref(false);
@@ -762,7 +737,21 @@ const maxable = ref(true);
const formRef = ref<FormInstance | null>(null);
const images = ref<ItemType[]>([]);
const content = ref('');
const disabled = ref(false);
// Markdown -> HTML 转换(编辑回显旧数据时,把 Markdown 内容转成富文本可识别的 HTML)
const md = markdownit();
/**
* 把后端 content 转为富文本编辑器可用的 HTML:
* - 已是 HTML(含标签)则原样返回,避免二次转义;
* - 否则按 Markdown 渲染为 HTML。
*/
const toEditorContent = (raw?: string): string => {
if (!raw) return '';
const s = raw.trim();
if (/<[a-z][\s\S]*>/i.test(s)) return raw;
return md.render(s);
};
// 当前选项卡
const active = ref('base');
@@ -969,34 +958,6 @@ const onDeleteEnsureTag = (tag: string) => {
ensureTag.value.splice(index, 1);
};
// 从文件库选择图片的回调
const onFileSelected = (data: FileRecord) => {
if (fileSelectCallback.value) {
// 使用文件的完整URL,确保有值
const imageUrl = data.url || data.path || '';
if (imageUrl) {
fileSelectCallback.value(imageUrl);
message.success('图片插入成功');
}
fileSelectCallback.value = null;
}
showFileSelector.value = false;
};
// 从视频库选择视频的回调
const onVideoSelected = (data: FileRecord) => {
if (videoSelectCallback.value) {
// 使用文件的完整URL,确保有值
const videoUrl = data.path || data.downloadUrl || '';
if (videoUrl) {
videoSelectCallback.value(videoUrl);
message.success('视频插入成功');
}
videoSelectCallback.value = null;
}
showVideoSelector.value = false;
};
// 🎨 智能一键排版 - 人性化设计
const handleAutoFormat = (editor: any) => {
try {
@@ -1740,88 +1701,6 @@ const batchApply = () => {
};
const editorRef = ref(null);
// 文件库选择弹窗状态
const showFileSelector = ref(false);
const fileSelectCallback = ref<((url: string) => void) | null>(null);
// 视频库选择弹窗状态
const showVideoSelector = ref(false);
const videoSelectCallback = ref<((url: string) => void) | null>(null);
// 编辑器可见性控制
// 删除了 TinyMCE 相关代码,改用 MdEditor
const editorVisible = ref(false);
// 当 Tab 切换到商品详情时,启用编辑器
watch(
() => active.value,
(key) => {
if (key === 'content') {
// 切换到商品详情 Tab 时,等待 DOM 渲染完成后启用编辑器
nextTick(() => {
setTimeout(() => {
editorVisible.value = true;
console.log('[shopGoodsEdit] Switched to content tab, editorVisible = true');
}, 100);
});
}
},
{ immediate: true }
);
const markdownToolbars = [
'bold',
'underline',
'italic',
'-',
'title',
'strikeThrough',
'quote',
'unorderedList',
'orderedList',
'task',
'-',
'codeRow',
'code',
'link',
'image',
'table',
'-',
'revoke',
'next',
'=',
'pageFullscreen',
'fullscreen',
'preview',
'previewOnly'
];
// Markdown 图片上传
const onMarkdownUploadImg = async (
files: File[],
callback: (urls: string[]) => void
) => {
try {
const uploadPromises = files.map(async (file) => {
if (file.size > 10 * 1024 * 1024) {
message.error(`图片 ${file.name} 大小超过10MB`);
return null;
}
const res = await uploadOss(file);
return res.url || res.path;
});
const results = await Promise.all(uploadPromises);
callback(results.filter(Boolean) as string[]);
} catch (err: any) {
message.error(err.message || '图片上传失败');
}
};
// TinyMCE config 已移除,改用 MdEditor
const config = ref({});
const {resetFields} = useForm(form, rules);
const COMMISSION_PERCENT_FIELDS = [
@@ -2164,7 +2043,7 @@ watch(
category.value = JSON.parse(props.data.category);
}
if (props.data.content) {
content.value = props.data.content;
content.value = toEditorContent(props.data.content);
}
isUpdate.value = true;
@@ -2200,17 +2079,4 @@ watch(
</script>
<style scoped>
/* 修复 MdEditor 工具栏图标过大 */
:deep(.md-editor-toolbar-wrapper .md-editor-icon) {
font-size: 16px !important;
width: 16px !important;
height: 16px !important;
}
:deep(.md-editor-toolbar-wrapper svg) {
width: 16px !important;
height: 16px !important;
}
:deep(.md-editor-toolbar-item) {
font-size: 16px !important;
}
</style>