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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user