微前端乾坤vue3项目使用tinymce,通过npm,yarn,pnpm包安装成功,但是引用报错无法使用
问题一:tinymce通过npm安装成功后,无法引用,由于官方做了限制,并且部分功能收费了,所以需要本包放到public目录下(包放到尾部),在index.html的head下面手动引入。使用自托管包时需要明确声明 GPL 许可,避免被禁用:license_key: “gpl”,也不行。

问题二: 乾坤子项目在index.html的head引入引入后,依然找不到,需要在主机座的ndex.html再次手动引入。
1.封装的文件放到components下面:YEditor文件夹-index.vue
<script setup>
import { uploadFileApi } from "@/api/file";
import { ElMessage } from "element-plus";
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import initOptions from "./config/options";
import initPlugins from "./config/plugins";
import initToolbar from "./config/toolbar";
import { getFileAccessHttpUrl } from "@/api/manage";
const uploadRef = ref();
const emit = defineEmits(["update:modelValue"]);
const props = defineProps({/*** 文本内容*/modelValue: {type: String,default: "",},/*** 高度*/height: {type: Number,default: 600,},/*** 插件*/plugins: {type: [Array, String],default: () => [...initPlugins],},/*** 工具栏*/toolbar: {type: [Array, String],default: () => [...initToolbar],},/*** 选项*/options: {type: Object,default: () => ({}),},// 允许配置 TinyMCE 资源路径baseURL: {type: String,default: "tinymce",},suffix: {type: String,default: ".min",},
});// 使用 crypto.randomUUID 生成唯一 ID(若环境不支持,可降级为 nanoid 或其他方案)
const editorId = `winter_${crypto?.randomUUID?.().replace(/-/g, "") ?? Math.ceil(Math.random() * 1000000)}`;let hasInit = false;const init = () => {// 如果已存在实例,先销毁,避免重复初始化const existing = window.tinymce?.get(editorId);if (existing) existing.destroy();// 使用包内模块,不再依赖 baseURL/suffix 的静态资源路径// 包装用户可能传入的回调,避免覆盖内部逻辑const userInitCb = props.options?.init_instance_callback;const userImgHandler = props.options?.images_upload_handler;window.tinymce.init({// 默认 → 用户 → 组件强制项(选择器/高度等)...initOptions,...props.options,// 使用自托管包时需要明确声明 GPL 许可,避免被禁用license_key: "gpl",// 使用安装的语言包language: "zh-Hans",toolbar: props.toolbar,plugins: props.plugins,selector: `#${editorId}`,min_height: props.options?.min_height ?? props.height,max_height: props.options?.max_height ?? props.height,// 不需要 external_plugins,当通过 ESM 方式引入时交由打包器处理async images_upload_handler(blobInfo) {if (typeof userImgHandler === "function") {return userImgHandler.call(this, blobInfo);}const file = new File([blobInfo.blob()], "cut.jpg");const formData = new FormData();formData.append("file", file);const res = await uploadFileApi(formData);return res.result.fileUrl;},init_instance_callback(editor) {if (!hasInit) {editor.setContent(props.modelValue || "");}hasInit = true;editor.on("NodeChange Change KeyUp SetContent", () => {emit("update:modelValue", editor.getContent());});},// 富文本默认交互file_picker_callback: (cb, _value, meta) => {if (meta.filetype === "media") {const input = document.createElement("input");input.type = "file";input.accept = ".mp4,.avi,.mov,.wmv,.flv,.webm";input.style.display = "none";document.body.appendChild(input);input.onchange = async (e) => {const file = e.target.files[0];const validTypes = ["video/mp4", "video/avi", "video/mov", "video/wmv", "video/flv", "video/webm"];if (!file || !validTypes.includes(file.type) || file.size > 50 * 1024 * 1024) {ElMessage({message: !file ? "请选择文件" : !validTypes.includes(file.type) ? "请选择有效的视频格式 (mp4, avi, mov, wmv, flv, webm)" : "视频文件不能大于50M",type: "warning",placement: "top",});document.body.removeChild(input);return;}try {ElMessage({ message: "视频上传中,请稍候...", type: "info", placement: "top" });const formData = new FormData();formData.append("file", file);const res = await uploadFileApi(formData);if (res?.result?.fileUrl) {cb(res.result.fileUrl, {source: res.result.fileUrl,poster: "",width: "100%",height: "auto",});ElMessage({ message: "视频上传成功", type: "success", placement: "top" });} else throw new Error();} catch {ElMessage({ message: "视频上传失败,请重试", type: "error", placement: "top" });}document.body.removeChild(input);};input.click();}},// 富文本自定义交互setup(editor) {editor.ui.registry.addButton("imgbtn", {text: "",icon: "image",tooltip: "插入图片",onAction: () => {uploadRef.value.click();},});},});
};const destroy = () => {const tinymce = window.tinymce?.get(editorId);if (tinymce) {tinymce.destroy();}
};const setContent = (value) => {const editor = window.tinymce?.get(editorId);if (editor) {editor.setContent(value);}
};const getContent = () => {const editor = window.tinymce?.get(editorId);return editor ? editor.getContent() : "";
};const onFileChange = async (e) => {const file = e.target.files[0];if (!file) return;const validTypes = ["image/jpeg", "image/png", "image/bmp", "image/svg+xml"];if (!validTypes.includes(file.type)) {ElMessage({message: "请选择有效的图片格式",type: "warning",placement: "top",});return;}const size = file.size;if (size > 2 * 1024 * 1024) {ElMessage({message: "图片资源不能大于2M",type: "warning",placement: "top",});return;}const formData = new FormData();formData.append("file", file);// const img = await fileToBase64(file)// window.tinymce.get(id.value).insertContent(`<img class="wscnph" src="${img}" >`)const res = await uploadFileApi(formData);window.tinymce.get(editorId).insertContent(`<img style="max-width: 100%" class="wscnph" src="${getFileAccessHttpUrl(res?.result?.fileUrl)}" >`);
};// 使用更安全的 hash 比较方式(可选)
const getHash = (str) => {let hash = 0;for (let i = 0; i < str.length; i++) {const char = str.charCodeAt(i);hash = (hash << 5) - hash + char;hash |= 0; // Convert to 32bit integer}return hash;
};watch(() => props.modelValue,(val) => {if (!hasInit) return;if (typeof val !== "string") val = "";const valHash = getHash(val);const contentHash = getHash(getContent());if (valHash !== contentHash) {nextTick(() => {setContent(val || "");});}},{ flush: "post" },
);onMounted(() => {init();
});onUnmounted(() => {destroy();
});
</script><template><div class="editor-container"><textarea :id="editorId" class="tinymce-textarea" /><input ref="uploadRef" type="file" hidden accept=".jpg,.jpeg,.png,.bmp,.svg" @change="onFileChange" /></div>
</template><style scoped>
.editor-container {width: 100%;
}
</style>
2.YEditor文件夹->config->options.ts
import plugins from "./plugins";
import toolbar from "./toolbar";
export default {language: "zh-Hans",toolbar,plugins,toolbar_sticky: true,statusbar: false, // 隐藏底部状态栏menubar: false,min_height: 600,max_height: 600,body_class: "panel-body ",object_resizing: false,content_style: "body { font-family:Helvetica,Arial,sans-serif; font-size:16px }",end_container_on_empty_block: true,powerpaste_word_import: "clean",// document_base_url: ,code_dialog_height: 450,code_dialog_width: 1000,// imagetools_cors_hosts: ['www.tinymce.com', 'codepen.io'],default_link_target: "_blank",link_title: false,nonbreaking_force_tab: true,relative_urls: false,convert_urls: false,fontsize_formats: "8px 10px 12px 14px 16px 18px 20px 22px 24px 26px 28px 30px 32px 34px 36px 38px 40px 42px 44px 46px 48px 50px 52px 54px 56px 58px 60px", // 可选的字体大小列表
};
3.YEditor文件夹->config->plugins.ts
import plugins from "./plugins";
import toolbar from "./toolbar";
export default {language: "zh-Hans",toolbar,plugins,toolbar_sticky: true,statusbar: false, // 隐藏底部状态栏menubar: false,min_height: 600,max_height: 600,body_class: "panel-body ",object_resizing: false,content_style: "body { font-family:Helvetica,Arial,sans-serif; font-size:16px }",end_container_on_empty_block: true,powerpaste_word_import: "clean",// document_base_url: ,code_dialog_height: 450,code_dialog_width: 1000,// imagetools_cors_hosts: ['www.tinymce.com', 'codepen.io'],default_link_target: "_blank",link_title: false,nonbreaking_force_tab: true,relative_urls: false,convert_urls: false,fontsize_formats: "8px 10px 12px 14px 16px 18px 20px 22px 24px 26px 28px 30px 32px 34px 36px 38px 40px 42px 44px 46px 48px 50px 52px 54px 56px 58px 60px", // 可选的字体大小列表
};
4.YEditor文件夹->config->toolbar.ts
// toolbar.js
const toolbar = ["undo redo blocks fontSize bold italic underline strikethrough forecolor backcolor link hr imgbtn table blockquote lineHeight alignleft aligncenter alignright alignjustify " +"bullist numlist outdent indent searchreplace fullscreen",
];export default toolbar;
页面引用:
<template><el-card id="platformConfig" class="card" :body-style="{ padding: '20px' }"><el-form ref="formRef" :model="model" :rules="rules" label-width="120px"><el-form-item label="底部介绍:" prop="footIntroduction"><y-editorv-model="model.footIntroduction":height="400"/></el-form-item><div style="display: flex; justify-content: center"><el-button type="primary" :loading="loading" @click="handle">保存</el-button></div></el-form></el-card>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useUserStore } from '@/store/modules/user'
import { ElMessage } from 'element-plus'
import YEditor from '@/components/YEditor/index.vue'
import { update, queryLastLogo } from '@/api/platform-config/index'defineOptions({name: 'Platformintroduction'
})// 响应式数据
const formRef = ref(null)
const loading = ref(false)const model = reactive({footIntroduction: '',
})// 自定义验证器
const validateRichText = (rule, value, callback) => {if (value) {// 去除HTML标签const textContent = value.replace(/<[^>]+>/g, '').trim()// 检查是否只包含空格或没有内容if (textContent.length === 0) {callback(new Error('内容不能为空'))} else {// 检查是否包含汉字const hasChineseChar = /[一-龥]/.test(textContent)if (!hasChineseChar) {callback(new Error('内容不能为空'))} else {callback()}}} else {callback()}
}// 表单验证规则
const rules = {footIntroduction: [{ required: true, message: '底部介绍不能为空', trigger: 'change' },{ validator: validateRichText, trigger: 'change' }]
}// 暴露方法给父组件使用
defineExpose({query
})
</script>