yunzer_go/pc/src/views/components/WangEditor.vue
2025-11-02 11:47:51 +08:00

627 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="wang-editor-wrapper" :class="{ focused: isFocused }">
<div ref="toolbarRef" class="toolbar-container"></div>
<div ref="editorRef" class="editor-container"></div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import '@wangeditor/editor/dist/css/style.css';
import { uploadFile } from '@/api/file';
interface Props {
modelValue: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const toolbarRef = ref<HTMLDivElement>();
const editorRef = ref<HTMLDivElement>();
const isFocused = ref(false);
let editorInstance: any = null;
let isDestroyed = false;
// 获取上传文件的 URL
const getUploadUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL;
};
// 获取 Authorization Header
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
};
// 上传图片处理函数
const handleUploadImage = async (file: File, insertFn: (url: string, alt?: string, href?: string) => void) => {
try {
const formData = new FormData();
formData.append('file', file);
const response: any = await uploadFile(formData, {
category: '编辑器',
});
// axios 拦截器已经返回了 response.data所以直接使用 response
if (response?.success) {
const fileUrl = response.data.file_url; // 例如: /uploads/2024/01/15/xxx.jpg
const baseUrl = getUploadUrl() || window.location.origin;
// 处理URL如果已经是完整URL则直接使用否则拼接baseUrl
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
// 确保URL以/开头baseUrl不以/结尾
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
insertFn(fullUrl, file.name, fullUrl);
ElMessage.success('图片上传成功');
} else {
ElMessage.error('上传失败:' + (response?.message || '未知错误'));
}
} catch (error: any) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
// 上传视频处理函数
const handleUploadVideo = async (file: File, insertFn: (url: string, poster?: string) => void) => {
try {
const formData = new FormData();
formData.append('file', file);
const response: any = await uploadFile(formData, {
category: '编辑器',
});
if (response?.success) {
const fileUrl = response.data.file_url;
const baseUrl = getUploadUrl() || window.location.origin;
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
insertFn(fullUrl, '');
ElMessage.success('视频上传成功');
} else {
ElMessage.error('上传失败:' + (response?.message || '未知错误'));
}
} catch (error: any) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
// 上传附件处理函数
const handleUploadAttachment = async (file: File, insertFn: (url: string, text?: string) => void) => {
try {
const formData = new FormData();
formData.append('file', file);
const response: any = await uploadFile(formData, {
category: '编辑器',
});
if (response?.success) {
const fileUrl = response.data.file_url;
const baseUrl = getUploadUrl() || window.location.origin;
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
insertFn(fullUrl, file.name);
ElMessage.success('附件上传成功');
} else {
ElMessage.error('上传失败:' + (response?.message || '未知错误'));
}
} catch (error: any) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
// 初始化编辑器
const initEditor = async () => {
if (!editorRef.value || !toolbarRef.value || isDestroyed) return;
try {
// 动态导入 wangEditor
const { createEditor, createToolbar } = await import('@wangeditor/editor');
const editorConfig = {
placeholder: '请输入内容...',
onChange: (editor: any) => {
if (!isDestroyed) {
const html = editor.getHtml();
emit('update:modelValue', html);
}
},
// 自定义上传配置
MENU_CONF: {
// 图片上传配置
uploadImage: {
server: '/api/files',
fieldName: 'file',
headers: getAuthHeaders(),
customUpload: async (file: File, insertFn: (url: string, alt?: string, href?: string) => void) => {
await handleUploadImage(file, insertFn);
},
allowedFileTypes: ['image/*'],
maxFileSize: 5 * 1024 * 1024, // 5MB
},
// 视频上传配置
uploadVideo: {
server: '/api/files',
fieldName: 'file',
headers: getAuthHeaders(),
customUpload: async (file: File, insertFn: (url: string, poster?: string) => void) => {
await handleUploadVideo(file, insertFn);
},
allowedFileTypes: ['video/*'],
maxFileSize: 100 * 1024 * 1024, // 100MB
},
// 附件上传配置
uploadAttachment: {
server: '/api/files',
fieldName: 'file',
headers: getAuthHeaders(),
customUpload: async (file: File, insertFn: (url: string, text?: string) => void) => {
await handleUploadAttachment(file, insertFn);
},
allowedFileTypes: ['*'],
maxFileSize: 50 * 1024 * 1024, // 50MB
},
},
};
// 创建编辑器
editorInstance = createEditor({
selector: editorRef.value,
html: props.modelValue || '',
config: editorConfig,
mode: 'default',
});
// 创建工具栏
createToolbar({
editor: editorInstance,
selector: toolbarRef.value,
config: {},
});
// 监听编辑器焦点事件(兼容不同版本的 API
nextTick(() => {
if (editorInstance) {
// 方法1: 使用 on 方法(如果存在)
if (typeof editorInstance.on === 'function') {
editorInstance.on('focus', () => {
isFocused.value = true;
});
editorInstance.on('blur', () => {
isFocused.value = false;
});
}
// 方法2: 直接在 DOM 元素上监听
const editorDom = editorRef.value;
if (editorDom) {
const textDom = editorDom.querySelector('.w-e-text');
if (textDom) {
textDom.addEventListener('focus', () => {
isFocused.value = true;
});
textDom.addEventListener('blur', () => {
isFocused.value = false;
});
}
}
}
});
} catch (error) {
console.error('Failed to initialize editor:', error);
}
};
// 监听外部值变化
watch(() => props.modelValue, (newVal) => {
if (editorInstance && newVal !== editorInstance.getHtml()) {
editorInstance.setHtml(newVal || '');
}
});
// 暴露方法
defineExpose({
clear: () => {
if (editorInstance) {
editorInstance.clear();
}
},
getContent: () => {
if (editorInstance) {
return editorInstance.getHtml();
}
return props.modelValue;
},
setContent: (content: string) => {
if (editorInstance) {
editorInstance.setHtml(content || '');
}
},
});
onMounted(() => {
nextTick(() => {
setTimeout(() => {
initEditor();
}, 100);
});
});
onBeforeUnmount(() => {
isDestroyed = true;
if (editorInstance) {
editorInstance.destroy();
editorInstance = null;
}
});
</script>
<style scoped lang="less">
.wang-editor-wrapper {
width: 100%;
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
background-color: var(--fill-color-blank);
transition: all 0.3s ease;
}
.toolbar-container {
border-bottom: 1px solid var(--border-color-lighter);
background-color: var(--fill-color-light);
transition: all 0.3s ease;
}
.editor-container {
min-height: 400px;
background-color: var(--fill-color-blank);
transition: background-color 0.3s ease;
}
</style>
<style lang="less">
// 全局样式WangEditor 编辑器主题适配
.wang-editor-wrapper {
// 工具栏样式
:deep(.w-e-toolbar) {
background-color: var(--fill-color-light) !important;
border-bottom-color: var(--border-color-lighter) !important;
border-bottom: 1px solid var(--border-color-lighter) !important;
// 工具栏按钮项
.w-e-bar-item {
button {
color: var(--text-color-primary) !important;
background-color: transparent !important;
border: none !important;
transition: all 0.2s ease !important;
&:hover:not(.disabled) {
background-color: var(--fill-color) !important;
color: var(--primary-color) !important;
}
&:active:not(.disabled) {
background-color: var(--fill-color-dark) !important;
}
&.active {
background-color: var(--fill-color-dark) !important;
color: var(--primary-color) !important;
}
&.disabled {
color: var(--text-color-disabled) !important;
cursor: not-allowed !important;
opacity: 0.5 !important;
}
}
// 按钮组分隔线
&::after {
background-color: var(--border-color-lighter) !important;
}
}
// 工具栏分割线
.w-e-bar-divider {
background-color: var(--border-color-lighter) !important;
}
}
// 编辑器文本容器
:deep(.w-e-text-container) {
background-color: var(--fill-color-blank) !important;
color: var(--text-color-primary) !important;
border: none !important;
// 编辑器内容区域
.w-e-text {
color: var(--text-color-primary) !important;
background-color: transparent !important;
min-height: 400px !important;
// 编辑器焦点状态
&:focus {
outline: none !important;
}
// 编辑器内的段落
p {
color: var(--text-color-primary) !important;
margin: 0.5em 0 !important;
}
// 编辑器内的标题
h1, h2, h3, h4, h5, h6 {
color: var(--text-color-primary) !important;
font-weight: 600 !important;
}
// 编辑器内的链接
a {
color: var(--primary-color) !important;
text-decoration: underline !important;
&:hover {
color: var(--primary-color) !important;
opacity: 0.8 !important;
}
}
// 编辑器内的代码
code {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
border: 1px solid var(--border-color-lighter) !important;
padding: 2px 6px !important;
border-radius: 3px !important;
}
// 编辑器内的代码块
pre {
background-color: var(--fill-color-light) !important;
border: 1px solid var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
border-radius: 4px !important;
code {
background-color: transparent !important;
border: none !important;
padding: 0 !important;
}
}
// 编辑器内的引用
blockquote {
border-left: 4px solid var(--border-color) !important;
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
padding: 0.6em 1.2em !important;
margin: 1em 0 !important;
}
// 编辑器内的表格
table {
border-collapse: collapse !important;
border: 1px solid var(--border-color-lighter) !important;
th, td {
border: 1px solid var(--border-color-lighter) !important;
background-color: var(--fill-color-blank) !important;
color: var(--text-color-primary) !important;
}
th {
background-color: var(--fill-color-light) !important;
}
}
// 编辑器内的列表
ul, ol {
color: var(--text-color-primary) !important;
}
li {
color: var(--text-color-primary) !important;
}
}
// 占位符样式
.placeholder {
color: var(--text-color-placeholder) !important;
}
}
// 菜单下拉框
:deep(.w-e-drop-panel) {
background-color: var(--bg-color-overlay) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--box-shadow) !important;
color: var(--text-color-primary) !important;
.w-e-list-item {
color: var(--text-color-primary) !important;
transition: all 0.2s ease !important;
&:hover {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
}
&.selected {
background-color: var(--fill-color-light) !important;
color: var(--primary-color) !important;
}
&.disabled {
color: var(--text-color-disabled) !important;
cursor: not-allowed !important;
&:hover {
background-color: transparent !important;
}
}
}
// 分隔线
.w-e-drop-panel-divider {
background-color: var(--border-color-lighter) !important;
}
}
// 工具栏下拉菜单
:deep(.w-e-toolbar-menu) {
background-color: var(--bg-color-overlay) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--box-shadow) !important;
.w-e-menu-item {
color: var(--text-color-primary) !important;
&:hover {
background-color: var(--fill-color-light) !important;
color: var(--text-color-primary) !important;
}
&.active {
background-color: var(--fill-color-light) !important;
color: var(--primary-color) !important;
}
}
}
// 模态框
:deep(.w-e-modal) {
background-color: var(--bg-color-overlay) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--box-shadow) !important;
.w-e-modal-header {
border-bottom: 1px solid var(--border-color-lighter) !important;
color: var(--text-color-primary) !important;
}
.w-e-modal-body {
background-color: var(--bg-color-overlay) !important;
color: var(--text-color-primary) !important;
input, textarea, select {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&:focus {
border-color: var(--primary-color) !important;
}
}
}
.w-e-modal-footer {
border-top: 1px solid var(--border-color-lighter) !important;
button {
background-color: var(--fill-color-blank) !important;
border-color: var(--border-color) !important;
color: var(--text-color-primary) !important;
&:hover {
background-color: var(--fill-color-light) !important;
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
}
}
}
// 工具栏图标颜色
:deep(.w-e-bar-item svg),
:deep(.w-e-bar-item .w-e-icon) {
fill: var(--text-color-primary) !important;
color: var(--text-color-primary) !important;
transition: fill 0.2s ease, color 0.2s ease !important;
}
:deep(.w-e-bar-item:hover:not(.disabled) svg),
:deep(.w-e-bar-item:hover:not(.disabled) .w-e-icon),
:deep(.w-e-bar-item.active svg),
:deep(.w-e-bar-item.active .w-e-icon) {
fill: var(--primary-color) !important;
color: var(--primary-color) !important;
}
// 工具栏按钮组
:deep(.w-e-bar-divider) {
background-color: var(--border-color-lighter) !important;
}
// 编辑区域边框
&.focused {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 1px var(--primary-color) inset !important;
}
}
// 深色主题额外适配
[data-theme="dark"] {
.wang-editor-wrapper {
:deep(.w-e-text-container .w-e-text) {
// 编辑器内的图片边框
img {
border: 1px solid var(--border-color-lighter) !important;
border-radius: 4px !important;
}
// 编辑器内的水平线
hr {
border-top-color: var(--border-color-lighter) !important;
}
// 编辑器内的表格行交替颜色
table tr:nth-child(even) {
background-color: var(--fill-color-extra-light) !important;
}
}
}
}
// Fix z-index for full-screen/maximize mode
:deep(.w-e-fullscreen),
:deep(.w-e-fullscreen *),
:deep(.w-e-modal),
:deep(.w-e-drop-panel) {
z-index: 99999 !important;
}
</style>