Files
zkxw/web-admin/src/components/TinyMceEditor/index.vue
T
2026-08-25 16:47:43 +08:00

317 lines
9.0 KiB
Vue

<template>
<div class="tinymce-editor-container">
<div v-if="showPrint" class="tinymce-editor-extra-bar">
<el-button size="small" @click="handlePrint">
<el-icon><Printer /></el-icon>
打印
</el-button>
</div>
<Editor
:id="editorId"
v-model="contentValue"
:init="editorInit"
:disabled="disabled"
tinymce-script-src="/tinymce/tinymce.min.js"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import Editor from '@tinymce/tinymce-vue'
import { Printer } from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import {
printSopHtml,
setupSopEditorInteractions,
SOP_EDITOR_CONTENT_STYLE,
getSopA4EditorHeightPx,
resolveSopPageCount
} from '@/utils/sop-print'
defineOptions({ name: 'TinyMceEditor' })
interface Props {
modelValue?: string
disabled?: boolean
height?: number
toolbar?: boolean
placeholder?: string
/** 附件上传 folder,与门户附件接口一致 */
uploadFolder?: string
/** 是否显示 SOP 打印按钮 */
showPrint?: boolean
/** 打印页标题 */
printTitle?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
disabled: false,
height: 400,
toolbar: true,
placeholder: '请输入内容...',
uploadFolder: 'production/process-sop',
showPrint: false,
printTitle: 'SOP'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const TINYMCE_Z_INDEX_FLOOR = 10000
const resolveTinyMceBaseZIndex = (): number => {
let maxZ = 3000
document.querySelectorAll('.el-overlay, .el-dialog, .el-popper').forEach((el) => {
const z = Number.parseInt(window.getComputedStyle(el).zIndex || '0', 10)
if (!Number.isNaN(z) && z > maxZ) maxZ = z
})
return Math.max(maxZ + 200, TINYMCE_Z_INDEX_FLOOR)
}
const syncTinyMceFloatingZIndex = (baseZ: number) => {
const z = String(baseZ)
const backdropZ = String(baseZ - 1)
document.querySelectorAll('.tox-tinymce-aux').forEach((el) => {
;(el as HTMLElement).style.zIndex = z
})
document.querySelectorAll('.tox-dialog-wrap').forEach((el) => {
;(el as HTMLElement).style.zIndex = z
})
document.querySelectorAll('.tox-dialog').forEach((el) => {
;(el as HTMLElement).style.zIndex = String(baseZ + 1)
})
document.querySelectorAll('.tox-dialog__backdrop').forEach((el) => {
;(el as HTMLElement).style.zIndex = backdropZ
})
document.querySelectorAll('.tox-menu, .tox-pop, .tox-toolbar__overflow, .tox-collection').forEach(
(el) => {
;(el as HTMLElement).style.zIndex = String(baseZ + 2)
}
)
}
const editorId = `tinymce-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
const contentValue = ref(props.modelValue)
const editorInstance = ref<{
getContent: (args?: { format?: string }) => string
} | null>(null)
watch(
() => props.modelValue,
(newVal) => {
if (newVal !== contentValue.value) {
contentValue.value = newVal
}
}
)
watch(contentValue, (newVal) => {
emit('update:modelValue', newVal)
})
async function handlePrint() {
// 从编辑器实时取 HTML,避免勾选态只在 iframe DOM 中、v-model 未同步
const liveHtml = editorInstance.value?.getContent() || contentValue.value || props.modelValue || ''
if (editorInstance.value) {
contentValue.value = liveHtml
}
await printSopHtml(liveHtml, props.printTitle)
}
const useSopA4Layout = computed(() => props.showPrint)
const sopPageCount = computed(() =>
useSopA4Layout.value
? resolveSopPageCount(contentValue.value || props.modelValue || '')
: 1
)
const editorInit = computed(() => {
const baseZIndex = resolveTinyMceBaseZIndex()
const sopA4 = useSopA4Layout.value
return {
skin_url: '/tinymce/skins/ui/oxide',
language: 'zh_CN',
language_url: '/tinymce/langs/zh_CN.js',
height: sopA4 ? getSopA4EditorHeightPx(sopPageCount.value) : props.height,
resize: !sopA4,
menubar: false,
statusbar: false,
readonly: props.disabled,
placeholder: props.placeholder,
plugins: props.toolbar
? [
'advlist',
'autolink',
'lists',
'link',
'image',
'charmap',
'searchreplace',
'visualblocks',
'code',
'fullscreen',
'insertdatetime',
'table',
'wordcount'
]
: [],
toolbar: props.toolbar
? 'undo redo | bold italic underline | alignleft aligncenter alignright | bullist numlist | table | link image | forecolor backcolor | removeformat | code fullscreen'
: false,
content_style: sopA4
? SOP_EDITOR_CONTENT_STYLE
: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', sans-serif;
font-size: 14px;
line-height: 1.6;
color: #334155;
padding: 16px;
}
table {
border-collapse: collapse;
width: 100%;
max-width: 100%;
margin-bottom: 16px;
}
table td,
table th {
border: 1px solid #e2e8f0;
padding: 8px;
word-wrap: break-word;
}
table th {
background-color: #f8fafc;
font-weight: 600;
}
`,
extended_valid_elements: '*[*]',
valid_elements: '*[*]',
table_toolbar:
'tableprops tablecellprops tabledelete | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol',
table_appearance_options: true,
table_grid: true,
table_resize_bars: true,
branding: false,
promotion: false,
base_z_index: baseZIndex,
images_upload_handler: (blobInfo: { blob: () => Blob; filename: () => string }, progress: (n: number) => void) => {
return new Promise<string>(async (resolve, reject) => {
try {
progress(10)
const file = new File([blobInfo.blob()], blobInfo.filename(), {
type: blobInfo.blob().type || 'image/jpeg'
})
const formData = new FormData()
formData.append('folder', props.uploadFolder)
formData.append('file', file)
progress(40)
const res = await fetchUploadAttachment(formData)
const fileUrl = res?.fileUrl
if (!fileUrl) {
reject('上传失败')
return
}
progress(100)
resolve(fileUrl)
} catch (error: unknown) {
const err = error as { message?: string }
reject(err?.message || '上传失败')
}
})
},
paste_data_images: true,
automatic_uploads: true,
file_picker_types: 'image',
images_file_types: 'jpg,jpeg,png,gif,bmp,webp',
image_advtab: true,
setup: (editor: {
on: (name: string, fn: ((e?: Event) => void) | (() => void)) => void
getContainer: () => HTMLElement
getBody: () => HTMLElement
getContent: (args?: { format?: string }) => string
undoManager: { transact: (fn: () => void) => void }
nodeChanged: () => void
fire: (name: string) => void
setDirty?: (state: boolean) => void
}) => {
const refreshFloatingLayers = () => {
const z = resolveTinyMceBaseZIndex()
syncTinyMceFloatingZIndex(z)
}
editor.on('init', refreshFloatingLayers)
editor.on('OpenWindow', refreshFloatingLayers)
editor.on('CloseWindow', refreshFloatingLayers)
editor.on('ShowMenu', refreshFloatingLayers)
editor.on('focus', refreshFloatingLayers)
editor.on('init', () => {
editorInstance.value = editor as { getContent: (args?: { format?: string }) => string }
if (props.disabled) {
const toolbar = editor.getContainer().querySelector('.tox-toolbar')
if (toolbar) {
toolbar.style.display = 'none'
}
}
if (sopA4) {
setupSopEditorInteractions(editor)
}
})
}
}
})
</script>
<style scoped lang="scss">
.tinymce-editor-container {
:deep(.tox-tinymce) {
border: 1px solid #dcdfe6;
border-radius: 8px;
}
:deep(.tox-editor-header) {
border-bottom: 1px solid #e2e8f0;
}
}
.tinymce-editor-extra-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
</style>
<style lang="scss">
.tox-tinymce-aux {
z-index: 10000 !important;
}
.tox-dialog-wrap {
z-index: 10000 !important;
}
.tox-dialog {
z-index: 10001 !important;
}
.tox-dialog__backdrop {
z-index: 9999 !important;
}
.tox-menu,
.tox-pop,
.tox-toolbar__overflow,
.tox-collection,
.tox-swatches,
.tox-selector {
z-index: 10002 !important;
}
</style>