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

问题二: 乾坤子项目在index.html的head引入引入后,依然找不到,需要在主机座的ndex.html再次手动引入。
1.封装的文件放到components下面:YEditor文件夹-index.vue
javascript
<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
javascript
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
javascript
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
javascript
// 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;
页面引用:
javascript
<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-editor
v-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>