背景:TinyMCE富文本使用云托管有限制,每个月只能渲染1000次,所以使用自托管模式:
官网地址:www.tiny.cloud/docs/tinymc… 自托管官网地址:www.tiny.cloud/get-tiny/
- 注意官网上的一句话:npm 下载到node_module下的内容默认是云托管模式
- 自托管建议直接下载.zip的包后放到项目的public下面;
- 官网不建议把富文本的内容放到代码中,经过vite等打包
效果:
- 在富文本的外层封装了一些方法:可以在外面调用一些方法实现对富文本的操作,自取,不用就删除;
- 另外,代码中的useProtectContent.ts的内容,是自定义的功能,我的项目需求是不允许对初始加载的原文直接删除,删除操作只能添加删除线,后续输入的内容可以删除,类似word中的审阅模式;不需要把这个删除,在各个模块不要引入即可
一、自托管 1、安装
vue的项目中先安装:npm install "@tinymce/tinymce-vue"
2、把官网下载的zip的包解压放在public下面
3、在index.html中引入脚本js
4、封装一个组件,目录如下:
index.vue中的代码如下
<template>
<div class="tinymce-editor-wrapper">
<!-- TinyMCE 编辑器 -->
<Editor
v-model="editorContent"
license-key="gpl"
tinymce-script-src="/tinymce/tinymce.min.js"
:init="mergedConfig"
:disabled="disabled"
@init="handleEditorInit"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Editor from '@tinymce/tinymce-vue'
import { getDefaultConfig } from './config/defaultConfig'
import { useTinyMCEOperations } from './hooks/useTinyMCEOperations'
import { useProtectContent } from './hooks/useProtectContent'
import type { TinyMCEEditorProps, TinyMCEEditorEmits } from './types'
// Props 定义
const props = withDefaults(defineProps<TinyMCEEditorProps>(), {
modelValue: '<p>欢迎使用 TinyMCE 富文本编辑器!</p>',
disabled: false,
height: 400,
config: () => ({})
})
// Emits 定义
const emit = defineEmits<TinyMCEEditorEmits>()
// 编辑器内容
const editorContent = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
emit('change', value)
}
})
// 存储编辑器实例
const editorInstance = ref<any>(null)
// 获取编辑器实例的方法
const getEditorInstance = () => editorInstance.value
// 使用操作 Hook
const {
addStrikethrough,
appendAfterString,
scrollToTarget,
addBackgroundColor
} = useTinyMCEOperations(getEditorInstance)
// 使用内容保护 Hook
const { setupProtection } = useProtectContent()
// 合并配置
const mergedConfig = computed(() => ({
...getDefaultConfig(props.height),
...props.config
}))
// 编辑器初始化完成
const handleEditorInit = (_evt: any, editor: any) => {
editorInstance.value = editor
// 设置删除线内容保护
setupProtection(editor)
emit('init', editor)
console.log('TinyMCE 编辑器初始化完成', editor)
}
// 暴露方法给父组件
defineExpose({
getContent: () => editorInstance.value?.getContent() || '',
setContent: (content: string) => {
if (editorInstance.value) {
editorInstance.value.setContent(content)
editorContent.value = content
}
},
getEditor: () => editorInstance.value,
addStrikethrough,
appendAfterString,
scrollToTarget,
addBackgroundColor
})
</script>
<style scoped>
.tinymce-editor-wrapper {
width: 100%;
}
/* TinyMCE 编辑器样式优化 */
:deep(.tox-tinymce) {
border: 1px solid #d9d9d9;
border-radius: 4px;
}
:deep(.tox-tinymce:hover) {
border-color: #40a9ff;
}
:deep(.tox-tinymce.tox-tinymce--focused) {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
</style>
config文件夹下的defaultConfig.ts的内容如下:
/**
* TinyMCE 默认配置
*/
export const getDefaultConfig = (height = 400) => ({
// 语言
language: 'zh_CN',
language_url: '/tinymce/langs/zh_CN.js',
// 编辑器高度
height,
// 菜单栏
menubar: false,
// 插件列表
plugins: [
'advlist',
'autolink',
'lists',
'link',
'image',
'charmap',
'preview',
'anchor',
'searchreplace',
'visualblocks',
'code',
'fullscreen',
'insertdatetime',
'media',
'table',
'help',
'wordcount'
],
// 工具栏配置
toolbar:
'undo redo | blocks fontfamily fontsize | ' +
'bold italic underline strikethrough subscript superscript | ' +
'forecolor backcolor | alignleft aligncenter alignright alignjustify | ' +
'bullist numlist outdent indent | removeformat | table | code',
// 工具栏模式
toolbar_mode: 'sliding',
// 字体列表配置(中文字体)
font_family_formats:
'宋体=SimSun;' +
'黑体=SimHei;' +
'微软雅黑=Microsoft YaHei;' +
'楷体=KaiTi;' +
'仿宋=FangSong;' +
'苹方=PingFang SC;' +
'Arial=arial,helvetica,sans-serif;' +
'Times New Roman=times new roman,times;' +
'Courier New=courier new,courier,monospace',
// 字体大小配置(使用 px 单位)
font_size_formats:
'12px 14px 16px 18px 20px 24px 28px 32px 36px 48px 56px 72px',
// 内容样式
content_style:
'body { font-family:Microsoft YaHei,SimSun,Arial,sans-serif; font-size:14px }',
// 启用快速工具栏
quickbars_selection_toolbar: 'bold italic | quicklink h2 h3 blockquote',
// 图片上传配置
images_upload_handler: (blobInfo: any) =>
new Promise((resolve, reject) => {
// 这里可以实现图片上传逻辑
// 示例:将图片转为 base64
const reader = new FileReader()
reader.readAsDataURL(blobInfo.blob())
reader.onload = () => {
resolve(reader.result as string)
}
reader.onerror = () => {
reject('图片上传失败')
}
}),
// 允许粘贴图片
paste_data_images: true,
// 其他配置
branding: false, // 隐藏 "Powered by TinyMCE"
resize: false, // 禁止调整大小
elementpath: false, // 隐藏底部元素路径
promotion: false, // 隐藏右上角 "Upgrade" 按钮
statusbar: false // 隐藏底部状态栏
})
hooks文件夹下的useTinyMCEOperations.ts
import { message } from 'ant-design-vue'
/**
* TinyMCE 字符串操作 Hook
*/
export const useTinyMCEOperations = (getEditorInstance: () => any) => {
/**
* 给指定的字符串添加删除线
* @param targetString 目标字符串
*/
const addStrikethrough = (targetString: string) => {
if (!targetString.trim()) {
message.warning('目标字符串不能为空')
return false
}
const editorInstance = getEditorInstance()
if (!editorInstance) {
message.error('编辑器未初始化')
return false
}
// 获取编辑器内容
const currentContent = editorInstance.getContent()
// 使用正则表达式查找目标字符串(全局匹配)
const regex = new RegExp(
targetString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'g'
)
// 检查是否找到目标字符串
if (!regex.test(currentContent)) {
message.warning(`未找到字符串:"${targetString}"`)
return false
}
// 替换所有匹配的字符串,添加删除线标签
const newContent = currentContent.replace(
regex,
`<del>${targetString}</del>`
)
// 设置新内容
editorInstance.setContent(newContent)
message.success(`已为 "${targetString}" 添加删除线`)
return true
}
/**
* 在指定的字符串后面添加新的字符串
* @param targetString 目标字符串
* @param appendText 要添加的新字符串
* @param textColor 添加文字的颜色(可选,十六进制颜色值,如 #ff0000)
*/
const appendAfterString = (
targetString: string,
appendText: string,
textColor?: string
) => {
if (!targetString.trim()) {
message.warning('目标字符串不能为空')
return false
}
if (!appendText.trim()) {
message.warning('要添加的字符串不能为空')
return false
}
const editorInstance = getEditorInstance()
if (!editorInstance) {
message.error('编辑器未初始化')
return false
}
// 获取编辑器内容
const currentContent = editorInstance.getContent()
// 转义特殊字符用于正则表达式
const escapedTarget = targetString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// 创建正则表达式,匹配目标字符串(可能被标签包裹)
const regex = new RegExp(`(<[^>]*>)*${escapedTarget}(<\\/[^>]*>)*`, 'g')
// 根据是否提供颜色参数,构建要添加的文本
const formattedAppendText = textColor
? `<span style="color: ${textColor}">${appendText}</span>`
: appendText
let matchCount = 0
const newContent = currentContent.replace(regex, (match: string) => {
matchCount++
// 检查匹配的字符串是否被标签包裹
const hasOpenTag = match.startsWith('<')
const hasCloseTag = match.endsWith('>')
if (hasOpenTag && hasCloseTag) {
// 被标签包裹的情况,在闭合标签后添加新字符串
return match + formattedAppendText
} else {
// 纯文本的情况,直接在后面添加
return match + formattedAppendText
}
})
// 检查是否找到目标字符串
if (matchCount === 0) {
message.warning(`未找到字符串:"${targetString}"`)
return false
}
// 设置新内容
editorInstance.setContent(newContent)
const colorInfo = textColor ? `(颜色: ${textColor})` : ''
message.success(
`已在 "${targetString}" 后添加 "${appendText}"${colorInfo}(共 ${matchCount} 处)`
)
return true
}
/**
* 滚动到指定字符串位置并高亮
* @param targetString 目标字符串
*/
const scrollToTarget = (targetString: string) => {
if (!targetString.trim()) {
message.warning('目标字符串不能为空')
return false
}
const editorInstance = getEditorInstance()
if (!editorInstance) {
message.error('编辑器未初始化')
return false
}
// 清除之前的搜索高亮
editorInstance.execCommand('mceRemoveSearchHighlight')
// 搜索字符串
const found = editorInstance.plugins.searchreplace.find(
targetString,
false,
false
)
if (!found || found === 0) {
message.warning(`未找到字符串:"${targetString}"`)
return false
}
// 获取编辑器的 body
const body = editorInstance.getBody()
// 查找第一个高亮的元素
const firstHighlight = body.querySelector('span[data-mce-bogus="1"]')
if (firstHighlight) {
// 滚动到第一个高亮元素
firstHighlight.scrollIntoView({
behavior: 'smooth',
block: 'center'
})
// 设置光标位置
editorInstance.selection.select(firstHighlight)
editorInstance.selection.collapse(false)
if (found === 1) {
message.success(`已定位到 "${targetString}"`)
} else {
message.success(`找到 ${found} 个匹配项,已定位到第一个`)
}
return true
}
return false
}
/**
* 给指定的字符串添加背景色
* @param targetString 目标字符串
* @param backgroundColor 背景颜色(十六进制)
*/
const addBackgroundColor = (
targetString: string,
backgroundColor: string
) => {
if (!targetString.trim()) {
message.warning('目标字符串不能为空')
return false
}
if (!backgroundColor) {
message.warning('请选择背景颜色')
return false
}
const editorInstance = getEditorInstance()
if (!editorInstance) {
message.error('编辑器未初始化')
return false
}
// 获取编辑器内容
const currentContent = editorInstance.getContent()
// 转义特殊字符
const escapedTarget = targetString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// 创建正则表达式,匹配目标字符串(可能被标签包裹)
const regex = new RegExp(`(<[^>]*>)*${escapedTarget}(<\\/[^>]*>)*`, 'g')
let matchCount = 0
const newContent = currentContent.replace(regex, (match: string) => {
matchCount++
// 检查匹配的字符串是否被标签包裹
const hasOpenTag = match.startsWith('<')
const hasCloseTag = match.endsWith('>')
if (hasOpenTag && hasCloseTag) {
// 被标签包裹的情况,在标签外添加背景色
return `<span style="background-color: ${backgroundColor}">${match}</span>`
} else {
// 纯文本的情况,直接添加背景色
return `<span style="background-color: ${backgroundColor}">${match}</span>`
}
})
// 检查是否找到目标字符串
if (matchCount === 0) {
message.warning(`未找到字符串:"${targetString}"`)
return false
}
// 设置新内容
editorInstance.setContent(newContent)
message.success(
`已为 "${targetString}" 添加背景色 ${backgroundColor}(共 ${matchCount} 处)`
)
return true
}
return {
addStrikethrough,
appendAfterString,
scrollToTarget,
addBackgroundColor
}
}
useProtectContent.ts内容:
/* eslint-disable max-depth */
/* eslint-disable max-lines-per-function */
/**
* 内容保护 Hook - 将删除操作转换为添加删除线
* 重构版本:使用更简洁的实现方式,减少复杂的 DOM 操作
* 支持原始内容保护:原始内容只能添加删除线,用户新输入的内容可以正常删除
*/
import { isOriginalContent, clearOriginalMark } from '../utils/contentMarker'
/**
* 检查节点或其父节点是否已有删除线样式
*/
const hasStrikethroughStyle = (node: Node | null): boolean => {
if (!node) return false
let current: Node | null = node
while (current && current.nodeType !== Node.DOCUMENT_NODE) {
if (current.nodeType === Node.ELEMENT_NODE) {
const element = current as HTMLElement
const tagName = element.tagName?.toLowerCase()
// 检查是否是删除线标签
if (tagName === 'del' || tagName === 'strike' || tagName === 's') {
return true
}
// 检查 style 属性
if (element.style?.textDecoration?.includes('line-through')) {
return true
}
}
current = current.parentNode
}
return false
}
/**
* 给删除线元素添加 data-original 属性
*/
const markStrikethroughAsOriginal = (node: Node | null) => {
console.log('🔍 markStrikethroughAsOriginal 被调用,输入节点:', node)
if (!node) {
console.log('❌ 节点为空,退出')
return
}
// 策略1:如果当前节点就是删除线元素
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement
const tagName = element.tagName?.toLowerCase()
if (tagName === 's' || tagName === 'strike' || tagName === 'del') {
console.log('✅ 当前节点就是删除线元素')
element.setAttribute('data-original', 'true')
console.log('✅ 已添加 data-original 属性')
return
}
}
// 策略2:向上查找删除线元素
let current: Node | null = node.parentNode
while (current && current.nodeType !== Node.DOCUMENT_NODE) {
if (current.nodeType === Node.ELEMENT_NODE) {
const element = current as HTMLElement
const tagName = element.tagName?.toLowerCase()
if (tagName === 's' || tagName === 'strike' || tagName === 'del') {
console.log('✅ 在父元素中找到删除线元素')
element.setAttribute('data-original', 'true')
console.log('✅ 已添加 data-original 属性')
return
}
}
current = current.parentNode
}
// 策略3:在当前节点内部查找删除线元素(适用于父容器的情况)
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement
const strikethroughElements = element.querySelectorAll('s, strike, del')
console.log(
`🔍 在节点内部找到 ${strikethroughElements.length} 个删除线元素`
)
if (strikethroughElements.length > 0) {
strikethroughElements.forEach((el) => {
el.setAttribute('data-original', 'true')
console.log('✅ 已给内部删除线元素添加 data-original 属性:', el)
})
return
}
}
console.log('❌ 没有找到删除线元素')
}
/**
* 处理有选中内容的情况
*/
const handleSelectedContent = (editor: any) => {
try {
const selection = editor.selection
const selectedText = selection.getContent({ format: 'text' })
// 如果没有选中文本内容,不做处理
if (!selectedText || selectedText.trim() === '') {
return
}
const selectedHtml = selection.getContent({ format: 'html' })
// 检查选中内容是否全部或部分已有删除线
const hasStrikethrough =
selectedHtml.includes('<del') ||
selectedHtml.includes('<strike') ||
selectedHtml.includes('<s>')
if (hasStrikethrough) {
// 如果已有删除线,跳过该内容并折叠选区到末尾
const range = selection.getRng()
range.collapse(false)
selection.setRng(range)
editor.notificationManager.open({
text: '选中内容包含删除线,已跳过',
type: 'info',
timeout: 1000
})
return
}
// 保存当前位置
const bookmark = selection.getBookmark()
// 给选中内容添加删除线
editor.execCommand('Strikethrough')
// 给新创建的删除线元素添加 data-original 属性
setTimeout(() => {
selection.moveToBookmark(bookmark)
const node = selection.getNode()
markStrikethroughAsOriginal(node)
// 折叠选区到末尾
const range = selection.getRng()
range.collapse(false)
selection.setRng(range)
}, 0)
editor.notificationManager.open({
text: '已添加删除线',
type: 'success',
timeout: 1000
})
} catch (err) {
console.error('处理选中内容失败:', err)
}
}
/**
* 处理 Backspace 键 - 给光标前一个字符添加删除线
*/
const handleBackspace = (editor: any) => {
try {
const selection = editor.selection
// 获取浏览器原生 Selection 对象
const win = editor.getWin()
const nativeSelection = win.getSelection()
if (!nativeSelection) {
return
}
// 保存当前位置
const currentRange = nativeSelection.getRangeAt(0).cloneRange()
// 使用原生 API 向前扩展选择一个字符
nativeSelection.modify('extend', 'backward', 'character')
// 获取选中的文本
const selectedText = nativeSelection.toString()
// 如果没有选中任何字符(可能在文档开头)
if (!selectedText || selectedText.length === 0) {
// 恢复原位置
nativeSelection.removeAllRanges()
nativeSelection.addRange(currentRange)
return
}
// 检查选中的字符是否已有删除线
const anchorNode = nativeSelection.anchorNode
if (hasStrikethroughStyle(anchorNode)) {
// 已有删除线,跳过这个字符,继续向前移动光标
const newRange = nativeSelection.getRangeAt(0)
newRange.collapse(true)
nativeSelection.removeAllRanges()
nativeSelection.addRange(newRange)
editor.notificationManager.open({
text: '该字符已有删除线',
type: 'info',
timeout: 800
})
return
}
// 同步选区到 TinyMCE
const tinyRange = editor.dom.createRng()
const nativeRange = nativeSelection.getRangeAt(0)
tinyRange.setStart(nativeRange.startContainer, nativeRange.startOffset)
tinyRange.setEnd(nativeRange.endContainer, nativeRange.endOffset)
selection.setRng(tinyRange)
// 保存位置
const bookmark = selection.getBookmark()
// 添加删除线
editor.execCommand('Strikethrough')
// 给新创建的删除线元素添加 data-original 属性,然后移动光标
setTimeout(() => {
selection.moveToBookmark(bookmark)
const node = selection.getNode()
markStrikethroughAsOriginal(node)
// 移动光标到删除线之前
const newRange = selection.getRng()
newRange.collapse(true)
selection.setRng(newRange)
}, 0)
} catch (err) {
console.error('处理 Backspace 失败:', err)
}
}
/**
* 处理 Delete 键 - 给光标后一个字符添加删除线
*/
const handleDelete = (editor: any) => {
try {
const selection = editor.selection
// 获取浏览器原生 Selection 对象
const win = editor.getWin()
const nativeSelection = win.getSelection()
if (!nativeSelection) {
return
}
// 保存当前位置
const currentRange = nativeSelection.getRangeAt(0).cloneRange()
// 使用原生 API 向后扩展选择一个字符
nativeSelection.modify('extend', 'forward', 'character')
// 获取选中的文本
const selectedText = nativeSelection.toString()
// 如果没有选中任何字符(可能在文档末尾)
if (!selectedText || selectedText.length === 0) {
// 恢复原位置
nativeSelection.removeAllRanges()
nativeSelection.addRange(currentRange)
return
}
// 检查选中的字符是否已有删除线
const anchorNode = nativeSelection.anchorNode
if (hasStrikethroughStyle(anchorNode)) {
// 已有删除线,跳过这个字符,继续向后移动光标
const newRange = nativeSelection.getRangeAt(0)
newRange.collapse(false)
nativeSelection.removeAllRanges()
nativeSelection.addRange(newRange)
editor.notificationManager.open({
text: '该字符已有删除线',
type: 'info',
timeout: 800
})
return
}
// 同步选区到 TinyMCE
const tinyRange = editor.dom.createRng()
const nativeRange = nativeSelection.getRangeAt(0)
tinyRange.setStart(nativeRange.startContainer, nativeRange.startOffset)
tinyRange.setEnd(nativeRange.endContainer, nativeRange.endOffset)
selection.setRng(tinyRange)
// 保存位置
const bookmark = selection.getBookmark()
// 添加删除线
editor.execCommand('Strikethrough')
// 给新创建的删除线元素添加 data-original 属性,然后移动光标
setTimeout(() => {
selection.moveToBookmark(bookmark)
const node = selection.getNode()
markStrikethroughAsOriginal(node)
// 移动光标到删除线之前(保持在原位置)
const newRange = selection.getRng()
newRange.collapse(true)
selection.setRng(newRange)
}, 0)
} catch (err) {
console.error('处理 Delete 失败:', err)
}
}
/**
* 检查节点是否需要保护(只检查直接父元素,不向上遍历)
*/
const checkShouldProtect = (node: Node): boolean => {
if (node.nodeType === Node.TEXT_NODE) {
const parentElement = node.parentElement
return parentElement?.hasAttribute('data-original') || false
}
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
return element.hasAttribute('data-original')
}
return false
}
/**
* 递归查找最深的文本节点
*/
const findDeepestTextNode = (
node: Node,
direction: 'last' | 'first'
): Node | null => {
if (node.nodeType === Node.TEXT_NODE) {
return node
}
if (node.nodeType === Node.ELEMENT_NODE) {
const children = node.childNodes
if (children.length === 0) return null
if (direction === 'last') {
// 从最后一个子节点开始递归查找
for (let i = children.length - 1; i >= 0; i--) {
const result = findDeepestTextNode(children[i], direction)
if (result) return result
}
} else {
// 从第一个子节点开始递归查找
for (let i = 0; i < children.length; i++) {
const result = findDeepestTextNode(children[i], direction)
if (result) return result
}
}
}
return null
}
/**
* 获取要删除的字符所在的节点(用于光标模式)
*/
const getCharNodeForDelete = (
targetNode: Node,
offset: number,
isBackspace: boolean
): Node | null => {
console.log('🔍 getCharNodeForDelete 被调用')
console.log('🔍 targetNode:', targetNode)
console.log('🔍 offset:', offset)
console.log('🔍 isBackspace:', isBackspace)
// 情况1:targetNode 是文本节点
if (targetNode.nodeType === Node.TEXT_NODE) {
if (isBackspace) {
// Backspace:检查光标前的字符
if (offset > 0) {
console.log('🔍 文本节点,光标前有字符,返回当前节点')
return targetNode
}
// 光标在文本开头,检查前一个兄弟节点
const prevSibling = targetNode.previousSibling
console.log('🔍 文本节点在开头,检查前一个兄弟:', prevSibling)
if (prevSibling) {
// 递归查找前一个兄弟节点中最后的文本节点
const result = findDeepestTextNode(prevSibling, 'last')
console.log('🔍 找到的最深文本节点:', result)
return result || prevSibling
}
// 如果没有前一个兄弟节点,向上查找父节点的前一个兄弟
console.log('🔍 没有前一个兄弟,向上查找父节点')
let parent = targetNode.parentNode
while (parent && parent.nodeType !== Node.DOCUMENT_NODE) {
const parentPrevSibling = parent.previousSibling
console.log('🔍 父节点的前一个兄弟:', parentPrevSibling)
if (parentPrevSibling) {
const result = findDeepestTextNode(parentPrevSibling, 'last')
console.log('🔍 找到的最深文本节点:', result)
return result || parentPrevSibling
}
parent = parent.parentNode
}
} else {
// Delete:检查光标后的字符
const textLength = targetNode.textContent?.length || 0
if (offset < textLength) {
console.log('🔍 文本节点,光标后有字符,返回当前节点')
return targetNode
}
// 光标在文本末尾,检查后一个兄弟节点
const nextSibling = targetNode.nextSibling
console.log('🔍 文本节点在末尾,检查后一个兄弟:', nextSibling)
if (nextSibling) {
// 递归查找后一个兄弟节点中第一个文本节点
const result = findDeepestTextNode(nextSibling, 'first')
console.log('🔍 找到的最深文本节点:', result)
return result || nextSibling
}
// 如果没有后一个兄弟节点,向上查找父节点的后一个兄弟
console.log('🔍 没有后一个兄弟,向上查找父节点')
let parent = targetNode.parentNode
while (parent && parent.nodeType !== Node.DOCUMENT_NODE) {
const parentNextSibling = parent.nextSibling
console.log('🔍 父节点的后一个兄弟:', parentNextSibling)
if (parentNextSibling) {
const result = findDeepestTextNode(parentNextSibling, 'first')
console.log('🔍 找到的最深文本节点:', result)
return result || parentNextSibling
}
parent = parent.parentNode
}
}
}
// 情况2:targetNode 是元素节点,offset 表示子节点索引
if (targetNode.nodeType === Node.ELEMENT_NODE) {
const childNodes = targetNode.childNodes
console.log('🔍 元素节点,子节点数量:', childNodes.length)
if (isBackspace) {
// Backspace:检查 offset-1 位置的子节点(光标前的节点)
if (offset > 0 && childNodes[offset - 1]) {
const prevChild = childNodes[offset - 1]
console.log('🔍 元素节点,光标前的子节点:', prevChild)
// 递归查找最后的文本节点
const result = findDeepestTextNode(prevChild, 'last')
console.log('🔍 找到的最深文本节点:', result)
return result || prevChild
}
} else {
// Delete:检查 offset 位置的子节点(光标后的节点)
if (offset < childNodes.length && childNodes[offset]) {
const nextChild = childNodes[offset]
console.log('🔍 元素节点,光标后的子节点:', nextChild)
// 递归查找第一个文本节点
const result = findDeepestTextNode(nextChild, 'first')
console.log('🔍 找到的最深文本节点:', result)
return result || nextChild
}
}
}
console.log('🔍 未找到要删除的字符节点')
return null
}
export const useProtectContent = () => {
/**
* 设置删除线内容保护
* @param editor TinyMCE 编辑器实例
* @param protectOriginal 是否只保护原始内容(true: 只保护原始内容,false: 保护所有内容)
*/
const setupProtection = (editor: any, protectOriginal = true) => {
if (!editor) return
// 中文输入法状态跟踪
let isComposing = false
// 方法1:拦截 TinyMCE 的删除命令(仅在需要保护时拦截)
editor.on('BeforeExecCommand', (e: any) => {
const command = e.command
// 拦截所有可能触发删除的命令
if (
command === 'Delete' ||
command === 'ForwardDelete' ||
command === 'mceInsertContent'
) {
// 检查是否需要保护
const selection = editor.selection
const range = selection.getRng()
const targetNode = range.collapsed
? range.startContainer
: range.commonAncestorContainer
// 如果是原始内容保护模式
if (protectOriginal) {
// 检查是否需要保护(只检查直接父元素)
const shouldProtect = checkShouldProtect(targetNode)
// 只拦截原始内容的删除
if (shouldProtect) {
e.preventDefault()
console.log('拦截了 TinyMCE 命令(原始内容):', command)
} else {
console.log('允许 TinyMCE 命令(用户内容):', command)
}
} else {
// 全保护模式,拦截所有删除
e.preventDefault()
console.log('拦截了 TinyMCE 命令(全保护):', command)
}
}
})
// 方法2:在原生 DOM 层面拦截(优先级最高)
const editorBody = editor.getBody()
if (editorBody) {
// 使用 capture 模式,在事件捕获阶段就拦截
editorBody.addEventListener(
'keydown',
(e: KeyboardEvent) => {
const isBackspace = e.keyCode === 8 || e.key === 'Backspace'
const isDelete = e.keyCode === 46 || e.key === 'Delete'
if (!isBackspace && !isDelete) return
// 获取当前选区
const selection = editor.selection
const range = selection.getRng()
console.log('=== 删除操作开始 ===')
console.log('protectOriginal:', protectOriginal)
console.log('range.collapsed:', range.collapsed)
// 如果启用了原始内容保护模式
if (protectOriginal) {
if (range.collapsed) {
// 光标模式:需要检查光标前/后的字符
const targetNode = range.startContainer
const offset = range.startOffset
console.log('光标模式 - targetNode:', targetNode)
console.log('光标模式 - nodeType:', targetNode.nodeType)
console.log('光标模式 - nodeName:', targetNode.nodeName)
console.log('光标模式 - offset:', offset)
console.log(
'光标模式 - textContent:',
targetNode.textContent?.substring(0, 50)
)
// 获取要删除的字符所在的节点
const charNode = getCharNodeForDelete(
targetNode,
offset,
isBackspace
)
console.log('要删除的字符所在节点:', charNode)
const shouldProtect = charNode
? checkShouldProtect(charNode)
: checkShouldProtect(targetNode)
console.log('光标模式 - shouldProtect:', shouldProtect)
if (!shouldProtect) {
console.log('✅ 允许删除用户输入的内容(光标模式)')
return
}
console.log('❌ 拦截原始内容的删除操作(光标模式)')
} else {
// 选区模式:需要智能处理混合内容
const startNode = range.startContainer
const endNode = range.endContainer
console.log('选区模式 - startNode:', startNode)
console.log('选区模式 - startNode.nodeType:', startNode.nodeType)
console.log('选区模式 - startNode.nodeName:', startNode.nodeName)
console.log('选区模式 - endNode:', endNode)
console.log('选区模式 - endNode.nodeType:', endNode.nodeType)
console.log('选区模式 - endNode.nodeName:', endNode.nodeName)
const startProtect = checkShouldProtect(startNode)
const endProtect = checkShouldProtect(endNode)
console.log('起始节点需要保护:', startProtect)
console.log('结束节点需要保护:', endProtect)
// 情况1:纯用户输入(起始和结束都不需要保护)
if (!startProtect && !endProtect) {
console.log('✅ 允许删除纯用户输入内容')
return
}
// 情况2:纯原始内容(起始和结束都需要保护)
if (startProtect && endProtect) {
console.log('❌ 拦截纯原始内容的删除,添加删除线')
// 继续执行添加删除线逻辑
} else {
// 情况3:混合内容(部分原始,部分用户输入)
console.log('⚠️ 混合内容:需要智能处理')
// TODO: 实现混合删除逻辑
// 暂时先拦截,添加删除线
}
}
} else {
console.log('全保护模式,拦截所有删除')
}
// 拦截删除操作(原始内容或全保护模式)
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
// 情况1:有选中内容 - 给选中的内容添加删除线
if (!range.collapsed) {
handleSelectedContent(editor)
return false
}
// 情况2:光标在某个位置 - 给光标前/后的字符添加删除线
if (isBackspace) {
handleBackspace(editor)
} else if (isDelete) {
handleDelete(editor)
}
return false
},
true // 使用捕获模式,优先级最高
)
}
// 方法3:TinyMCE 的 keydown 事件作为备份(已由 DOM 捕获阶段处理,这里可以移除)
// editor.on('keydown', ...) - 不再需要,DOM 捕获阶段已经处理
// 处理粘贴事件,清除粘贴内容的 data-original 标记
// 使粘贴的内容成为"用户输入",可以被删除
editor.on('PastePreProcess', (e: any) => {
if (protectOriginal && e.content) {
// 创建临时容器
const tempDiv = document.createElement('div')
tempDiv.innerHTML = e.content
// 清除所有元素的 data-original 标记
const clearMarks = (element: Element) => {
clearOriginalMark(element)
}
Array.from(tempDiv.children).forEach((child) => {
clearMarks(child)
})
// 更新粘贴内容
e.content = tempDiv.innerHTML
console.log('已清除粘贴内容的原始标记')
}
})
// 监听输入事件,处理用户输入继承父元素格式的问题
if (protectOriginal) {
// 检查光标是否在删除线元素内
const findStrikethroughParent = (
node: Node | null
): HTMLElement | null => {
let current = node
while (current && current.nodeType !== Node.DOCUMENT_NODE) {
if (current.nodeType === Node.ELEMENT_NODE) {
const element = current as HTMLElement
const tagName = element.tagName?.toLowerCase()
if (tagName === 's' || tagName === 'strike' || tagName === 'del') {
return element
}
}
current = current.parentNode
}
return null
}
// 辅助函数:在删除线后插入文本
const insertAfterStrikethrough = (
strikethroughParent: HTMLElement,
inputChar: string,
selection: Selection
) => {
const parent = strikethroughParent.parentNode
if (!parent) return
const nextSibling = strikethroughParent.nextSibling
const newTextNode = document.createTextNode(inputChar)
if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE) {
// 追加到已有文本节点
const existingText = nextSibling.textContent || ''
nextSibling.textContent = inputChar + existingText
const newRange = document.createRange()
newRange.setStart(nextSibling, inputChar.length)
newRange.setEnd(nextSibling, inputChar.length)
selection.removeAllRanges()
selection.addRange(newRange)
} else if (
nextSibling &&
nextSibling.nodeType === Node.ELEMENT_NODE &&
(nextSibling as Element).tagName === 'SPAN'
) {
// 插入到 span 内
const spanElement = nextSibling as Element
const firstChild = spanElement.firstChild
if (firstChild && firstChild.nodeType === Node.TEXT_NODE) {
const existingText = firstChild.textContent || ''
firstChild.textContent = inputChar + existingText
const newRange = document.createRange()
newRange.setStart(firstChild, inputChar.length)
newRange.setEnd(firstChild, inputChar.length)
selection.removeAllRanges()
selection.addRange(newRange)
} else {
spanElement.insertBefore(newTextNode, spanElement.firstChild)
const newRange = document.createRange()
newRange.setStart(newTextNode, inputChar.length)
newRange.setEnd(newTextNode, inputChar.length)
selection.removeAllRanges()
selection.addRange(newRange)
}
} else {
// 创建新的 span(用户输入内容用红色标记)
const newSpan = document.createElement('span')
newSpan.style.color = 'red' // 用户输入的内容显示为红色
newSpan.appendChild(newTextNode)
parent.insertBefore(newSpan, nextSibling)
const newRange = document.createRange()
newRange.setStart(newTextNode, inputChar.length)
newRange.setEnd(newTextNode, inputChar.length)
selection.removeAllRanges()
selection.addRange(newRange)
}
console.log('🔧 已在删除线外插入新字符')
}
// 在字符输入前拦截(beforeinput 事件)
const editorBody = editor.getBody()
editorBody.addEventListener(
'beforeinput',
(e: InputEvent) => {
// 只处理字符输入
if (
e.inputType !== 'insertText' &&
e.inputType !== 'insertCompositionText'
) {
return
}
// 关键修复:使用 TinyMCE 编辑器的 window 对象,而不是全局 window
const editorWin = editor.getWin()
const selection = editorWin.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
const cursorNode = range.startContainer
console.log('⌨️ beforeinput - 准备输入:', e.data)
console.log('⌨️ 当前节点:', cursorNode)
console.log('⌨️ 节点类型:', cursorNode.nodeType)
console.log('⌨️ 节点名称:', cursorNode.nodeName)
console.log('⌨️ 节点内容:', cursorNode.textContent?.substring(0, 50))
console.log('⌨️ 是否正在输入中文:', isComposing)
// 如果正在输入中文,不拦截(等待 compositionend)
if (isComposing) {
console.log('⌨️ 中文输入中,跳过拦截')
return
}
// 检查是否在删除线内
const strikethroughParent = findStrikethroughParent(cursorNode)
console.log('⌨️ 找到的删除线父元素:', strikethroughParent)
if (strikethroughParent) {
console.log('🔧 在删除线内输入,拦截并手动处理')
e.preventDefault()
const inputChar = e.data || ''
insertAfterStrikethrough(strikethroughParent, inputChar, selection)
editor.nodeChanged()
return
}
// 检查是否在原始内容区域
const isOriginal = isOriginalContent(cursorNode)
console.log('⌨️ 是否是原始内容:', isOriginal)
if (isOriginal) {
// 在原始内容区域输入,需要拦截并用 span 包裹
console.log('🔧 在原始区域输入,拦截并用 span 包裹')
e.preventDefault()
const inputChar = e.data || ''
// 检查光标是否在一个没有 data-original 的 span 内(用户之前输入的)
let targetSpan: HTMLElement | null = null
let parent = cursorNode.parentElement
while (parent) {
if (
parent.tagName === 'SPAN' &&
!parent.hasAttribute('data-original')
) {
targetSpan = parent
break
}
// 如果遇到有 data-original 的元素,停止查找
if (parent.hasAttribute('data-original')) {
break
}
parent = parent.parentElement
}
if (targetSpan && cursorNode.nodeType === Node.TEXT_NODE) {
// 光标在用户之前输入的 span 内,直接追加文本
console.log('🔧 追加到已有的用户输入 span')
const textNode = cursorNode as Text
const offset = range.startOffset
const beforeText =
textNode.textContent?.substring(0, offset) || ''
const afterText = textNode.textContent?.substring(offset) || ''
const newText = beforeText + inputChar + afterText
textNode.textContent = newText
// 移动光标
const newRange = document.createRange()
const newOffset =
(typeof offset === 'number' ? offset : 0) + inputChar.length
newRange.setStart(textNode, newOffset)
newRange.setEnd(textNode, newOffset)
selection.removeAllRanges()
selection.addRange(newRange)
} else {
// 创建新的 span 元素包裹用户输入(用户输入内容用红色标记)
console.log('🔧 创建新的 span 包裹输入')
const newSpan = document.createElement('span')
newSpan.style.color = 'red' // 用户输入的内容显示为红色
const textNode = document.createTextNode(inputChar)
newSpan.appendChild(textNode)
// 在当前光标位置插入
range.deleteContents() // 删除选中内容(如果有)
range.insertNode(newSpan)
// 将光标移动到新插入内容的末尾
const newRange = document.createRange()
newRange.setStartAfter(newSpan)
newRange.setEndAfter(newSpan)
selection.removeAllRanges()
selection.addRange(newRange)
}
console.log('🔧 已处理用户输入')
editor.nodeChanged()
} else {
console.log('⌨️ 不是原始内容,允许正常输入')
}
},
true
)
// 监听中文输入法事件
editorBody.addEventListener('compositionstart', () => {
console.log('🎌 compositionstart - 开始输入中文')
isComposing = true
})
editorBody.addEventListener('compositionupdate', (e: any) => {
console.log('🎌 compositionupdate - 中文输入更新:', e.data)
})
editorBody.addEventListener('compositionend', (e: any) => {
console.log('🎌 compositionend - 中文输入完成:', e.data)
isComposing = false
// 中文输入完成后,需要检查并处理
setTimeout(() => {
const editorWin = editor.getWin()
const selection = editorWin.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
const cursorNode = range.startContainer
console.log('🎌 检查中文输入位置')
console.log('🎌 当前节点:', cursorNode)
// 检查是否在删除线内
const strikethroughParent = findStrikethroughParent(cursorNode)
if (strikethroughParent) {
console.log('🎌 中文输入在删除线内,需要移出')
// 找到刚输入的中文文本
const inputText = e.data || ''
if (inputText) {
// 删除删除线内的中文
const textNode = cursorNode as Text
const text = textNode.textContent || ''
const newText = text.replace(inputText, '')
textNode.textContent = newText
// 在删除线后插入(用户输入内容用红色标记)
const newSpan = editor.getDoc().createElement('span')
newSpan.style.color = 'red' // 用户输入的内容显示为红色
newSpan.textContent = inputText
strikethroughParent.parentNode?.insertBefore(
newSpan,
strikethroughParent.nextSibling
)
// 移动光标到新 span 后
const newRange = editor.getDoc().createRange()
newRange.setStartAfter(newSpan)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
editor.nodeChanged()
}
return
}
// 检查是否在原始内容区域
const isOriginal = isOriginalContent(cursorNode)
if (isOriginal) {
console.log('🎌 中文输入在原始区域,需要用 span 包裹')
const inputText = e.data || ''
if (inputText) {
// 找到刚输入的文本在文本节点中的位置
const textNode = cursorNode as Text
const text = textNode.textContent || ''
const inputIndex = text.lastIndexOf(inputText)
if (inputIndex !== -1) {
// 分割文本节点
const beforeText = text.substring(0, inputIndex)
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
const afterText = text.substring(inputIndex + inputText.length)
// 创建新的 span 包裹输入的中文(用户输入内容用红色标记)
const newSpan = editor.getDoc().createElement('span')
newSpan.style.color = 'red' // 用户输入的内容显示为红色
newSpan.textContent = inputText
// 重建 DOM 结构
const parent = textNode.parentNode
if (parent) {
// 创建前半部分文本节点
if (beforeText) {
const beforeNode = editor
.getDoc()
.createTextNode(beforeText)
parent.insertBefore(beforeNode, textNode)
}
// 插入新 span
parent.insertBefore(newSpan, textNode)
// 创建后半部分文本节点
if (afterText) {
const afterNode = editor.getDoc().createTextNode(afterText)
parent.insertBefore(afterNode, textNode)
}
// 删除原文本节点
parent.removeChild(textNode)
// 移动光标到新 span 后
const newRange = editor.getDoc().createRange()
newRange.setStartAfter(newSpan)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
editor.nodeChanged()
}
}
}
}
}, 0)
})
}
// 监听内容变化,维护 data-original 属性
if (protectOriginal) {
// 存储初始的原始元素(在加载时标记)
const originalElements = new Set<Element>()
// 初始化时收集所有原始元素
setTimeout(() => {
const body = editor.getBody()
const allOriginalElements = body.querySelectorAll(
'[data-original="true"]'
)
allOriginalElements.forEach((el: Element) => originalElements.add(el))
console.log('📋 初始化:收集到', originalElements.size, '个原始元素')
}, 100)
editor.on('NodeChange', () => {
const body = editor.getBody()
// 策略1:维护删除线元素的 data-original 属性
const strikethroughElements = body.querySelectorAll('s, strike, del')
strikethroughElements.forEach((el: Element) => {
let parent = el.parentElement
let parentHasOriginal = false
while (parent && parent !== body) {
if (parent.hasAttribute('data-original')) {
parentHasOriginal = true
break
}
parent = parent.parentElement
}
if (parentHasOriginal && !el.hasAttribute('data-original')) {
el.setAttribute('data-original', 'true')
console.log('🔧 NodeChange: 恢复删除线的 data-original 属性')
}
})
// 策略2:恢复原始容器元素的 data-original 属性
// 查找包含删除线的 span 和 p 元素
originalElements.forEach((originalEl) => {
// 查找编辑器中对应的元素(通过类名、标签等特征)
if (originalEl.nodeType === Node.ELEMENT_NODE) {
const element = originalEl as HTMLElement
const tagName = element.tagName?.toLowerCase()
// 如果是 p 或 span 元素,查找包含删除线的对应元素
if (tagName === 'p' || tagName === 'span') {
// 通过子元素特征(包含删除线)来识别
const currentElements = body.querySelectorAll(
`${tagName}:has(s[data-original="true"]), ${tagName}:has(strike[data-original="true"]), ${tagName}:has(del[data-original="true"])`
)
currentElements.forEach((el: Element) => {
if (!el.hasAttribute('data-original')) {
el.setAttribute('data-original', 'true')
console.log(
'🔧 NodeChange: 恢复容器元素的 data-original 属性:',
tagName
)
}
})
}
}
})
})
}
const mode = protectOriginal ? '原始内容保护' : '全内容保护'
console.log(`📝 审查模式已启用 - ${mode} - 删除操作将转换为添加删除线`)
}
/**
* 移除保护(如果需要)
*/
const removeProtection = (editor: any) => {
if (!editor) return
console.log('📝 审查模式已关闭')
}
return {
setupProtection,
removeProtection
}
}
types文件夹下的index.ts
/**
* TinyMCE 编辑器组件 Props
*/
export interface TinyMCEEditorProps {
modelValue?: string // v-model 绑定的内容
disabled?: boolean // 是否禁用编辑器
height?: number // 编辑器高度
config?: Record<string, any> // 自定义配置
}
/**
* TinyMCE 编辑器组件 Emits
*/
export type TinyMCEEditorEmits = {
(e: 'update:modelValue', value: string): void
(e: 'init', editor: any): void
(e: 'change', value: string): void
}
/**
* TinyMCE 编辑器实例方法
*/
export interface TinyMCEEditorInstance {
getContent: () => string
setContent: (content: string) => void
getEditor: () => any
addStrikethrough: (targetString: string) => boolean
appendAfterString: (
targetString: string,
appendText: string,
textColor?: string
) => boolean
scrollToTarget: (targetString: string) => boolean
addBackgroundColor: (targetString: string, backgroundColor: string) => boolean
}
怎么使用:父组件代码
<template>
<div class="review-records-container">
<div class="editor-wrapper">
<h2 class="page-title">审查记录编辑</h2>
<!-- TinyMCE 编辑器组件 -->
<TinyMCEEditor
ref="editorRef"
v-model="content"
:height="400"
@init="handleEditorInit"
@change="handleContentChange"
/>
<!-- 字符串操作按钮 -->
<div class="operation-buttons">
<h3 class="section-title">字符串操作测试</h3>
<div class="button-group">
<a-button type="primary" @click="handleAddStrikethrough">
给 “合同编号” 添加删除线
</a-button>
<a-button type="primary" @click="handleAppendString">
在 “合同编号” 后添加 "富文本编辑器"
</a-button>
<a-button type="primary" @click="handleAppendStringWithColor">
在 “合同编号” 后添加红色 "【重点】"
</a-button>
<a-button type="primary" @click="handleScrollToTarget">
定位到 “合同编号”
</a-button>
<a-button type="primary" @click="handleAddBackgroundColor">
给 “合同编号” 添加黄色背景
</a-button>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<h3 class="section-title">编辑器操作</h3>
<div class="button-group">
<a-button type="primary" @click="handleLoadData">
模拟加载数据
</a-button>
<a-button @click="handleClear">清空内容</a-button>
<a-button type="primary" @click="handleSave">保存</a-button>
<a-button @click="handleGetContent">获取内容</a-button>
</div>
</div>
<!-- 内容预览区域 -->
<div v-if="previewContent" class="preview-section">
<h3>内容预览:</h3>
<div class="preview-content" v-html="previewContent"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import TinyMCEEditor from '@/components/TinyMCEEditor/index.vue'
import type { TinyMCEEditorInstance } from '@/components/TinyMCEEditor/types'
// 编辑器引用
const editorRef = ref<TinyMCEEditorInstance>()
// 编辑器内容
const content = ref('')
// 预览内容
const previewContent = ref('')
// 编辑器初始化完成
const handleEditorInit = (editor: any) => {
console.log('TinyMCE 编辑器初始化完成', editor)
message.success('编辑器加载成功')
}
// 内容变化
const handleContentChange = (value: string) => {
console.log('内容已变化:', value)
}
// ========== 字符串操作测试 ==========
// 添加删除线
const handleAddStrikethrough = () => {
editorRef.value?.addStrikethrough('合同编号')
}
// 追加字符串
const handleAppendString = () => {
editorRef.value?.appendAfterString('合同编号', '富文本编辑器')
}
// 追加带颜色的字符串
const handleAppendStringWithColor = () => {
editorRef.value?.appendAfterString('合同编号', '【重点】', '#ff0000')
}
// 定位到目标
const handleScrollToTarget = () => {
editorRef.value?.scrollToTarget('合同编号')
}
// 添加背景色
const handleAddBackgroundColor = () => {
editorRef.value?.addBackgroundColor('北京市朝阳区', '#ffff00')
}
// ========== 编辑器操作 ==========
// 模拟异步加载数据
const handleLoadData = () => {
message.info('正在加载数据...')
// 模拟异步请求(1秒后返回数据)
setTimeout(() => {
content.value = `
<p class="MsoNormal"><s><span style="font-family: 宋体;">合同编号</span></s><span style="font-family: 宋体;">:</span>FLC-2024-<s><span style="font-family: 宋体;">001</span></s><span style="font-family: 宋体;">发现的地方大幅度发地方吗</span></p>
<p class="MsoNormal"><span style="font-family: 宋体;">签订地点:北京市朝阳区</span></p>
<p class="MsoNormal"><span style="font-family: 宋体;">签订日期:</span>2025年4月5日</p>
<p class="MsoNormal"><!-- [if !supportLists]--><span style="mso-list: Ignore;">第一条 </span><!--[endif]--><strong><span style="font-family: 宋体;">合同双方</span></strong><strong><span class="15"> </span></strong></p>
<p class="MsoNormal"><!-- [if !supportLists]--><span style="mso-list: Ignore;">第二条 </span><!--[endif]--><strong><span style="font-family: 宋体;">TinyMCE</span></strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">甲方(出租人):</span></strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">公司名称:中融信达融资租赁有限公司</span></strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">法定代表人:李明远</span></strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">注册地址:北京市朝阳区金融大街</span>8号国贸大厦B座12层</strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">统一社会信用代码:</span>91110105MA01XK7Y3P</strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">联系电话:</span>010-88886666</strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">乙方(承租人):</span></strong></p>
<p class="MsoNormal"><span style="font-family: 宋体;">公司名称:华兴智能制造有限公司</span></p>
<p class="MsoNormal"><span style="font-family: 宋体;">法定代表人:张伟强</span></p>
<p class="MsoNormal"><span style="font-family: 宋体;">注册地址:江苏省苏州市工业园区星湖街</span>188号科技产业园A栋5楼</p>
<p class="MsoNormal"><span style="font-family: 宋体;">统一社会信用代码:</span>91320594MA21N5LJ8Q</p>
<p class="MsoNormal"><span style="font-family: 宋体;">联系电话:</span>0512-66669999</p>
<p class="MsoNormal"><span style="font-family: 宋体;">甲乙双方根据《中华人民共和国民法典》《中华人民共和国合同法》及相关法律法规,本着平等自愿、诚实信用的原则,经协商一致,就融资租赁事宜订立本合同。</span></p>
<p class="MsoNormal"><strong><span class="15"><span style="font-family: 宋体;">第二条</span> <span style="font-family: 宋体;">租赁物</span></span></strong></p>
<p class="MsoNormal">2.1 租赁物名称:数控五轴加工中心设备</p>
<p class="MsoNormal">2.2 型号规格:ZX-5000CNC</p>
<p class="MsoNormal">2.3 制造厂商:江苏精工机械股份有限公司</p>
<p class="MsoNormal">2.4 数量:2台</p>
<p class="MsoNormal">2.5 出厂编号:JG20240301、JG20240302</p>
<p class="MsoNormal">2.6 技术参数及配置详见附件一《租赁物技术说明书》</p>
<p class="MsoNormal">2.7 租赁物总价款:人民币贰佰肆拾万元整(¥2,400,000.00)</p>
<p class="MsoNormal"><strong><span class="15"><span style="font-family: 宋体;">第三
`
message.success('数据加载成功')
}, 1000)
}
// 清空内容
const handleClear = () => {
content.value = ''
previewContent.value = ''
message.info('内容已清空')
}
// 保存内容
const handleSave = () => {
if (
!content.value ||
content.value.trim() === '<p></p>' ||
content.value.trim() === ''
) {
message.warning('内容为空,无需保存')
return
}
// 这里可以实现保存到后端的逻辑
console.log('保存的内容:', content.value)
message.success('保存成功')
}
// 获取内容
const handleGetContent = () => {
const editorContent = editorRef.value?.getContent()
if (
!editorContent ||
editorContent.trim() === '<p></p>' ||
editorContent.trim() === ''
) {
message.warning('内容为空')
return
}
previewContent.value = editorContent
console.log('当前内容:', editorContent)
message.success('内容已在下方预览')
}
</script>
<style scoped>
.review-records-container {
padding: 24px;
background: #f5f5f5;
min-height: 100vh;
}
.editor-wrapper {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #1d2129;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 2px solid #1890ff;
}
.operation-buttons,
.action-buttons {
margin-top: 24px;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #1d2129;
margin-bottom: 16px;
}
.button-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.preview-section {
margin-top: 24px;
padding: 16px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #e8e8e8;
}
.preview-section h3 {
font-size: 16px;
font-weight: 600;
color: #1d2129;
margin-bottom: 12px;
}
.preview-content {
padding: 16px;
background: white;
border-radius: 4px;
min-height: 100px;
line-height: 1.6;
}
</style>