317 lines
9.0 KiB
Vue
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>
|