| | |
| | | <form class="protocol-form" @submit.prevent="submitForm"> |
| | | <section class="editor-shell" :class="{ fullscreen }"> |
| | | <div v-if="canEditProtocol" class="editor-toolbar" role="toolbar" aria-label="富文本工具栏"> |
| | | <button type="button" title="加粗" @click="exec('bold')"><strong>B</strong></button> |
| | | <button type="button" title="斜体" @click="exec('italic')"><em>I</em></button> |
| | | <button type="button" title="文字颜色" @click="exec('foreColor', '#111827')"><span class="font-color">A</span></button> |
| | | <button type="button" title="标题字号" @click="exec('fontSize', '4')"><span class="font-size">T<small>T</small></span></button> |
| | | <button type="button" title="无序列表" @click="exec('insertUnorderedList')"><span class="list-icon unordered"></span></button> |
| | | <button type="button" title="有序列表" @click="exec('insertOrderedList')"><span class="list-icon ordered"></span></button> |
| | | <button type="button" title="左对齐" @click="exec('justifyLeft')"><span class="align-icon left"></span></button> |
| | | <button type="button" title="居中" @click="exec('justifyCenter')"><span class="align-icon center"></span></button> |
| | | <button type="button" title="右对齐" @click="exec('justifyRight')"><span class="align-icon right"></span></button> |
| | | <button type="button" title="撤销" @click="exec('undo')"><span class="undo-icon">↶</span></button> |
| | | <button type="button" title="重做" @click="exec('redo')"><span class="redo-icon">↷</span></button> |
| | | <button type="button" title="加粗" @mousedown.prevent="exec('bold')"><strong>B</strong></button> |
| | | <button type="button" title="斜体" @mousedown.prevent="exec('italic')"><em>I</em></button> |
| | | <button type="button" title="文字颜色" @mousedown.prevent="exec('foreColor', '#111827')"><span class="font-color">A</span></button> |
| | | <button type="button" title="标题字号" @mousedown.prevent="exec('fontSize', '4')"><span class="font-size">T<small>T</small></span></button> |
| | | <button type="button" title="无序列表" @mousedown.prevent="exec('insertUnorderedList')"><span class="list-icon unordered"></span></button> |
| | | <button type="button" title="有序列表" @mousedown.prevent="exec('insertOrderedList')"><span class="list-icon ordered"></span></button> |
| | | <button type="button" title="左对齐" @mousedown.prevent="exec('justifyLeft')"><span class="align-icon left"></span></button> |
| | | <button type="button" title="居中" @mousedown.prevent="exec('justifyCenter')"><span class="align-icon center"></span></button> |
| | | <button type="button" title="右对齐" @mousedown.prevent="exec('justifyRight')"><span class="align-icon right"></span></button> |
| | | <button type="button" title="撤销" @mousedown.prevent="exec('undo')"><span class="undo-icon">↶</span></button> |
| | | <button type="button" title="重做" @mousedown.prevent="exec('redo')"><span class="redo-icon">↷</span></button> |
| | | <button type="button" title="全屏编辑" @click="toggleFullscreen"><span class="fullscreen-icon"></span></button> |
| | | </div> |
| | | <div |
| | | ref="editor" |
| | | class="protocol-editor" |
| | | :class="{ 'is-empty': editorEmpty }" |
| | | :contenteditable="canEditProtocol" |
| | | data-placeholder="请输入" |
| | | @input="syncEditor" |
| | | @focus="handleEditorFocus" |
| | | @input="handleEditorInput" |
| | | @mouseup="saveSelection" |
| | | @keyup="saveSelection" |
| | | @keydown.enter="handleEditorEnter" |
| | | @paste="handlePaste" |
| | | ></div> |
| | | </section> |
| | |
| | | return { |
| | | saving: false, |
| | | fullscreen: false, |
| | | editorEmpty: true, |
| | | savedRange: null, |
| | | form: { |
| | | protocolType, |
| | | protocolName, |
| | |
| | | this.$nextTick(() => { |
| | | if (this.$refs.editor) { |
| | | this.$refs.editor.innerHTML = this.form.protocolContent |
| | | this.savedRange = null |
| | | this.ensureEditorBlock(false) |
| | | this.updateEditorEmpty() |
| | | } |
| | | }) |
| | | }) |
| | |
| | | this.denyMiddlePlatformWrite() |
| | | return |
| | | } |
| | | this.$refs.editor && this.$refs.editor.focus() |
| | | const editor = this.$refs.editor |
| | | if (!editor) { |
| | | return |
| | | } |
| | | editor.focus() |
| | | this.restoreSelection() |
| | | if (command === 'justifyLeft' || command === 'justifyCenter' || command === 'justifyRight') { |
| | | document.execCommand('formatBlock', false, 'div') |
| | | } |
| | | if (command !== 'undo' && command !== 'redo') { |
| | | document.execCommand('styleWithCSS', false, true) |
| | | } |
| | | document.execCommand(command, false, value || null) |
| | | this.saveSelection() |
| | | this.syncEditor() |
| | | }, |
| | | // 目的:聚焦编辑器时确保块级结构存在,并记录当前光标位置供工具栏命令复用。 |
| | | handleEditorFocus() { |
| | | this.ensureEditorBlock() |
| | | this.saveSelection() |
| | | }, |
| | | // 目的:输入后同步表单内容,并保存最新选区,避免点击工具栏时丢失格式作用位置。 |
| | | handleEditorInput() { |
| | | this.syncEditor() |
| | | this.saveSelection() |
| | | }, |
| | | // 目的:仅保存编辑器内部选区,防止页面其他区域的选区被富文本命令误用。 |
| | | saveSelection() { |
| | | const editor = this.$refs.editor |
| | | const selection = window.getSelection() |
| | | if (!editor || !selection || !selection.rangeCount) { |
| | | return |
| | | } |
| | | const range = selection.getRangeAt(0) |
| | | const container = range.commonAncestorContainer |
| | | if (container === editor || editor.contains(container)) { |
| | | this.savedRange = range.cloneRange() |
| | | } |
| | | }, |
| | | // 目的:执行工具栏命令前恢复编辑器选区,使居中等块级格式能够应用到选中段落。 |
| | | restoreSelection() { |
| | | if (!this.savedRange) { |
| | | return |
| | | } |
| | | const selection = window.getSelection() |
| | | selection.removeAllRanges() |
| | | selection.addRange(this.savedRange) |
| | | }, |
| | | // 目的:确保编辑内容由块级行容器承载,使换行和对齐样式能够进入保存的 HTML。 |
| | | ensureEditorBlock(moveCaret = true) { |
| | | const editor = this.$refs.editor |
| | | if (!editor || editor.querySelector('div, p, ul, ol, blockquote, table')) { |
| | | return |
| | | } |
| | | const block = document.createElement('div') |
| | | const rootTextAlign = editor.style.textAlign |
| | | while (editor.firstChild) { |
| | | block.appendChild(editor.firstChild) |
| | | } |
| | | if (!block.childNodes.length) { |
| | | block.appendChild(document.createElement('br')) |
| | | } |
| | | if (rootTextAlign) { |
| | | block.style.textAlign = rootTextAlign |
| | | editor.style.textAlign = '' |
| | | } |
| | | editor.appendChild(block) |
| | | const selection = moveCaret ? window.getSelection() : null |
| | | if (selection) { |
| | | const range = document.createRange() |
| | | range.selectNodeContents(block) |
| | | range.collapse(false) |
| | | selection.removeAllRanges() |
| | | selection.addRange(range) |
| | | } |
| | | this.updateEditorEmpty() |
| | | }, |
| | | // 目的:显式插入新的块级段落,确保回车换行能够作为 HTML 结构持久化到数据库。 |
| | | handleEditorEnter(event) { |
| | | if (!this.canEditProtocol) { |
| | | event.preventDefault() |
| | | this.denyMiddlePlatformWrite() |
| | | return |
| | | } |
| | | event.preventDefault() |
| | | this.ensureEditorBlock() |
| | | document.execCommand('defaultParagraphSeparator', false, 'div') |
| | | document.execCommand('insertParagraph', false, null) |
| | | this.syncEditor() |
| | | }, |
| | | // 目的:切换编辑器全屏状态,并在切换后保持编辑焦点。 |
| | |
| | | }, |
| | | // 目的:把编辑器 DOM 内容同步回表单,并再次清洗 HTML。 |
| | | syncEditor() { |
| | | this.form.protocolContent = this.$refs.editor ? this.sanitizeHtml(this.$refs.editor.innerHTML) : '' |
| | | this.form.protocolContent = this.serializeEditorContent() |
| | | this.updateEditorEmpty() |
| | | }, |
| | | // 目的:将编辑器顶层内容统一为块级段落,并用换行符分隔后提交数据库。 |
| | | serializeEditorContent() { |
| | | const editor = this.$refs.editor |
| | | if (!editor) { |
| | | return '' |
| | | } |
| | | const container = document.createElement('div') |
| | | const blockTags = ['DIV', 'P', 'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'TABLE', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'] |
| | | let currentBlock = null |
| | | Array.prototype.slice.call(editor.childNodes).forEach(node => { |
| | | const isBlock = node.nodeType === 1 && blockTags.indexOf(node.nodeName) !== -1 |
| | | if (isBlock) { |
| | | currentBlock = null |
| | | container.appendChild(node.cloneNode(true)) |
| | | return |
| | | } |
| | | if (!currentBlock) { |
| | | currentBlock = document.createElement('div') |
| | | container.appendChild(currentBlock) |
| | | } |
| | | currentBlock.appendChild(node.cloneNode(true)) |
| | | }) |
| | | const html = Array.prototype.slice.call(container.childNodes) |
| | | .map(node => node.outerHTML) |
| | | .join('\n') |
| | | return this.sanitizeHtml(html) |
| | | }, |
| | | // 目的:根据编辑器真实文本和媒体内容维护占位提示显示状态。 |
| | | updateEditorEmpty() { |
| | | const editor = this.$refs.editor |
| | | if (!editor) { |
| | | this.editorEmpty = true |
| | | return |
| | | } |
| | | const text = String(editor.textContent || '').replace(/\u200B/g, '').trim() |
| | | this.editorEmpty = !text && !editor.querySelector('img, video, audio, table, hr') |
| | | }, |
| | | // 目的:校验并保存协议配置;中台只读模式下阻止提交。 |
| | | submitForm() { |
| | |
| | | this.$modal.msgError('请输入入园承诺内容') |
| | | return |
| | | } |
| | | const submittedContent = this.form.protocolContent |
| | | this.saving = true |
| | | saveProtocolConfig({ |
| | | ...this.form, |
| | |
| | | enabled: true |
| | | }).then(res => { |
| | | const data = getResponseData(res) || {} |
| | | this.form.protocolContent = this.sanitizeHtml(data.protocolContent || this.form.protocolContent) |
| | | if (this.$refs.editor) { |
| | | this.$refs.editor.innerHTML = this.form.protocolContent |
| | | this.form = { |
| | | ...this.form, |
| | | ...data, |
| | | protocolType, |
| | | protocolName, |
| | | protocolContent: submittedContent |
| | | } |
| | | this.updateEditorEmpty() |
| | | this.$modal.msgSuccess('保存成功') |
| | | }).finally(() => { |
| | | this.saving = false |
| | |
| | | } |
| | | }) |
| | | }) |
| | | template.content.querySelectorAll('[align]').forEach(node => { |
| | | const align = String(node.getAttribute('align') || '').toLowerCase() |
| | | if (['left', 'center', 'right', 'justify'].indexOf(align) !== -1) { |
| | | node.style.textAlign = align |
| | | } |
| | | node.removeAttribute('align') |
| | | }) |
| | | return template.innerHTML |
| | | }, |
| | | // 目的:将纯文本转义为安全 HTML,供纯文本粘贴时保留换行展示。 |