This commit is contained in:
oneao committed 2026-03-23 17:29:20 +08:00
1 parent 62550b8182
commit 3e172a1d53
26 files changed
+4820 -413

No files matched your search

@@ -0,0 +1,63 @@
import { Boot, IDomEditor, IButtonMenu } from "@wangeditor/editor";
// 当前的点击处理器,始终指向最新传入的函数
let currentClickHandler: (() => void) | null = null;
// 是否已经注册过菜单
let hasRegistered = false;
class PreviewButtonMenu implements IButtonMenu {
title = "预览";
iconSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16">
<path fill="currentColor" fill-rule="evenodd" d="M12 4.57a.5.5 0 0 0-.024-.235l-.013-.063a1.5 1.5 0 0 0-.18-.434c-.092-.15-.222-.28-.482-.54L8.711.707c-.259-.26-.389-.39-.54-.483a1.5 1.5 0 0 0-.496-.193a.5.5 0 0 0-.235-.024C7.329.004 7.194.004 7.015.004h-2.21c-1.68 0-2.52 0-3.16.327a3.02 3.02 0 0 0-1.31 1.31C.008 2.283.008 3.12.008 4.8v6.4c0 1.68 0 2.52.327 3.16a3.02 3.02 0 0 0 1.31 1.31c.642.327 1.48.327 3.16.327h2.423c.401 0 .602-.523.347-.832a.45.45 0 0 0-.345-.168H4.8c-.857 0-1.44-.001-1.89-.038c-.438-.036-.663-.1-.819-.18a2 2 0 0 1-.874-.874c-.08-.156-.145-.38-.18-.819c-.036-.45-.037-1.03-.037-1.89v-6.4c0-.857 0-1.44.037-1.89c.036-.438.101-.663.18-.819c.192-.376.498-.682.874-.874c.156-.08.381-.145.82-.18C3.36.997 3.94.997 4.8.997H7v3.5a.5.5 0 0 0 .5.5H11v.547c0 .25.207.45.456.473c.285.025.543-.188.543-.474V4.99c0-.178 0-.313-.005-.425zM8 1.41L10.59 4H8z" clip-rule="evenodd"/>
<path fill="currentColor" fill-rule="evenodd" d="M11 15c.834 0 1.61-.255 2.25-.691l1.47 1.47a.749.749 0 1 0 1.06-1.06l-1.47-1.47c.436-.641.691-1.41.691-2.25c0-2.21-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4zm0-1c1.66 0 3-1.34 3-3s-1.34-3-3-3s-3 1.34-3 3s1.34 3 3 3" clip-rule="evenodd"/>
</svg>
`;
tag = "button";
getValue(): string | boolean {
return "";
}
isActive(): boolean {
return false;
}
isDisabled(): boolean {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
exec(editor: IDomEditor): void {
if (this.isDisabled()) return;
currentClickHandler?.(); // 始终执行最新的回调
}
}
// 提供注册函数,允许多次调用,但只注册一次菜单
export function registerPreviewMenu(handleClick: () => void) {
currentClickHandler = handleClick;
if (hasRegistered) return;
try {
Boot.registerMenu({
key: "preview",
factory: () => new PreviewButtonMenu()
});
hasRegistered = true;
} catch (e) {
if (!/duplicate/i.test(String(e))) {
console.warn("注册预览菜单失败:", e);
}
}
}
// 菜单插入配置
export const toolbarConfig = {
excludeKeys: ["codeBlock", "fullScreen", "code"],
insertKeys: {
index: 32,
keys: ["preview"]
}
};
@@ -0,0 +1,236 @@
<script setup>
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import { ElMessage } from "element-plus";
import {
computed,
defineExpose,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
watch,
} from "vue";
import { uploadFile, uploadImage, uploadVideo } from "@/utils/file";
import { registerPreviewMenu, toolbarConfig } from "./config";
import Direcotry from "./modules/directory.vue";
import Preview from "./modules/preview.vue";
import "@wangeditor/editor/dist/css/style.css";
const props = defineProps({
displayMode: {
type: String,
default: "edit", // edit / view
},
modelValue: {
type: String,
default: "",
},
showDirecotry: {
type: Boolean,
default: false,
},
moduleId: {
type: String,
required: true,
},
father: {
type: [String, Number],
required: true,
},
});
const emit = defineEmits(["update:modelValue"]);
const previewHtml = ref("");
const showPreview = ref(false);
// 内容
const valueHtml = ref("");
onMounted(() => {
registerPreviewMenu(() => {
previewHtml.value = valueHtml.value;
showPreview.value = true;
});
});
// 编辑器实例
const editorRef = shallowRef();
watch(
() => props.modelValue,
(val) => {
valueHtml.value = val;
},
{ immediate: true },
);
const toolbarClass = computed(() =>
props.displayMode === "view" ? "toolbar-view" : "toolbar-edit",
);
const editorClass = computed(() => (props.displayMode === "view" ? "editor-view" : "editor-edit"));
defineExpose({
getText: () => {
return editorRef.value ? editorRef.value.getText() : "";
},
});
function handleChange() {
emit("update:modelValue", valueHtml.value);
}
// ================= 编辑器配置 =================
const editorConfig = {
placeholder: props.displayMode === "edit" ? "请输入内容..." : "",
readOnly: props.displayMode === "view",
MENU_CONF: {},
};
// 图片上传
editorConfig.MENU_CONF.uploadImage = {
async customBrowseAndUpload(insertFn) {
try {
const data = await uploadImage();
if (!data) return;
const res = await uploadFile(
data.blob,
data.fileName,
data.fileExt,
data.fileSize,
0,
props.moduleId,
props.father,
"",
);
insertFn(res.mx_mapfilename, res.mx_filename, "");
} catch (e) {
ElMessage.error("图片上传失败");
console.log(e);
}
},
};
// 视频上传
editorConfig.MENU_CONF.uploadVideo = {
async customBrowseAndUpload(insertFn) {
try {
const data = await uploadVideo();
if (!data) return;
const res = await uploadFile(
data.blob,
data.fileName,
data.fileExt,
data.fileSize,
0,
props.moduleId,
props.father,
"",
);
insertFn(res.mx_mapfilename, res.mx_filename, "");
} catch (e) {
ElMessage.error("视频上传失败");
}
},
};
// 销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor) editor.destroy();
});
function handleCreated(editor) {
editorRef.value = editor;
nextTick(() => {});
}
</script>
<template>
<div v-loading="false" class="w-full h-full editor-container">
<div class="main">
<Toolbar
v-if="props.displayMode === 'edit'"
class="toolbar"
:class="toolbarClass"
:editor="editorRef"
:default-config="toolbarConfig"
/>
<Editor
key="main"
v-model="valueHtml"
class="editor"
:class="editorClass"
:default-config="editorConfig"
@on-change="handleChange"
@on-created="handleCreated"
/>
</div>
<Direcotry v-if="showDirecotry" v-model:content="valueHtml" class="directory" />
</div>
<Preview v-model:visible="showPreview" :content="previewHtml" />
</template>
<style scoped>
.toolbar {
}
.toolbar-view {
}
.toolbar-edit {
border: 1px solid #ccc;
}
.editor {
overflow-y: hidden;
}
/* 原来 .editor 内嵌的 :deep */
.editor :deep(.w-e-text-container video) {
width: 100% !important;
}
.editor-view {
height: auto;
}
/* 原来 .editor-view 内嵌的 :deep */
.editor-view :deep(.w-e-text-container) {
background-color: transparent;
}
.editor-view :deep(.w-e-text-container .w-e-scroll [data-slate-editor]) {
overflow-y: auto;
padding: 0 !important;
}
.editor-edit {
height: 500px !important;
border: 1px solid #ccc;
border-top: transparent;
}
.editor-container {
display: flex;
gap: 44px;
}
/* 原来 .editor-container 内嵌的 .directory */
.editor-container .directory {
width: 200px;
height: 450px;
}
/* 原来 .editor-container 内嵌的 .main */
.editor-container .main {
flex: 1;
}
</style>
@@ -0,0 +1,169 @@
<template>
<el-card shadow="never" class="directory-card" :class="{ collapsed }">
<!-- 头部 -->
<div class="directory-header" @click="toggleCollapse">
<div class="directory-header-left">
<el-icon>
<Folder />
</el-icon>
<span>目录</span>
</div>
<el-icon>
<ArrowDown v-if="!collapsed" />
<ArrowRight v-else />
</el-icon>
</div>
<!-- 内容 -->
<el-scrollbar v-show="!collapsed" class="directory-content">
<div v-for="item in directory" :key="item.id" class="directory-item" :class="`level-${item.level}`"
@click="scrollTo(item.id)">
{{ item.text }}
</div>
</el-scrollbar>
</el-card>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { Folder, ArrowDown, ArrowRight } from '@element-plus/icons-vue'
defineOptions({
name: 'WangEditorDirectory'
})
const props = defineProps({
content: String,
top: Number
})
const directory = ref([])
const collapsed = ref(false)
const toggleCollapse = () => {
collapsed.value = !collapsed.value
}
const scrollTo = (id) => {
const el = document.getElementById(id)
if (!el) return
if (props.top) {
const rect = el.getBoundingClientRect()
const scrollTop =
window.pageYOffset || document.documentElement.scrollTop
const top = rect.top + scrollTop - props.top
window.scrollTo({
top,
behavior: 'smooth'
})
} else {
el.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
}
}
function throttle(fn) {
let running = false
return (...args) => {
if (running) return
running = true
requestAnimationFrame(() => {
fn(...args)
running = false
})
}
}
function generateDirectory() {
const root = document.querySelector('.editor')
if (!root) return
const headings = root.querySelectorAll('h1, h2, h3')
directory.value = Array.from(headings).map((h, i) => {
if (!h.id) h.id = `section-${i + 1}`
return {
id: h.id,
text: h.textContent || '',
level: Number(h.tagName[1]),
index: i
}
})
}
const updateDirectory = throttle(generateDirectory)
watch(
() => props.content,
() => {
nextTick(updateDirectory)
},
{ immediate: true }
)
</script>
<style scoped>
.directory-card {
height: 100%;
display: flex;
flex-direction: column;
}
/* 折叠态 */
.directory-card.collapsed {
height: 44px;
}
.directory-header {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
font-weight: 600;
padding: 0 12px;
border-bottom: 1px solid var(--el-border-color);
}
.directory-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.directory-content {
padding: 12px;
}
.directory-item {
padding: 6px 8px;
cursor: pointer;
border-radius: 4px;
}
.directory-item:hover {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.directory-item.level-1 {
font-size: 15px;
font-weight: 600;
}
.directory-item.level-2 {
padding-left: 20px;
font-size: 14px;
}
.directory-item.level-3 {
padding-left: 40px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,133 @@
<script setup>
import { Close, FullScreen } from '@element-plus/icons-vue'
import { Editor } from '@wangeditor/editor-for-vue'
import { ref, shallowRef, watch } from 'vue'
import '@wangeditor/editor/dist/css/style.css'
// ================= Props =================
const props = defineProps({
content: {
type: String,
default: '',
},
visible: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:visible'])
// ================= 编辑器配置 =================
const editorConfig = {
placeholder: '暂无内容',
readOnly: true,
MENU_CONF: {},
}
const fullscreen = ref(false)
const localVisible = ref(props.visible)
const editorRef = shallowRef()
// 同步 visible
watch(
() => props.visible,
(val) => {
localVisible.value = val
},
)
// 编辑器创建
function handleCreated(editor) {
editorRef.value = editor
if (props.content) {
editor.setHtml(props.content)
}
}
// 关闭弹窗
function handleCloseDialog() {
fullscreen.value = false
localVisible.value = false
emit('update:visible', false)
}
// 打开弹窗
function handleOpenDialog() {
if (editorRef.value) {
editorRef.value.setHtml(props.content || '')
}
}
</script>
<template>
<el-dialog
v-bind="$attrs"
v-model="localVisible"
:fullscreen="fullscreen"
:show-close="false"
@close="handleCloseDialog"
@open="handleOpenDialog"
>
<template #header>
<div class="header">
<div />
<div class="title">
预览
</div>
<div class="actions">
<div class="action" @click="fullscreen = !fullscreen">
<el-icon>
<FullScreen />
</el-icon>
</div>
<div class="action" @click="handleCloseDialog">
<el-icon>
<Close />
</el-icon>
</div>
</div>
</div>
</template>
<div class="editor-container" :class="[{ 'editor-fullscreen': fullscreen }]">
<Editor
key="preview"
:default-config="editorConfig"
style="height: 100%"
@on-created="handleCreated"
/>
</div>
</el-dialog>
</template>
<style scoped>
.header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 18px;
}
.actions {
display: flex;
gap: 16px;
font-size: 18px;
}
.actions .action {
cursor: pointer;
}
.editor-container {
height: 500px;
}
.editor-container.editor-fullscreen {
height: calc(100vh - 100px);
}
</style>