zjk
2026-07-31 8665daa7aa863f96d0d8f27c167edae1efc38022
ruoyi-ui/src/views/visitor-admin/system/protocol.vue
@@ -10,25 +10,30 @@
      <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>
@@ -55,6 +60,8 @@
    return {
      saving: false,
      fullscreen: false,
      editorEmpty: true,
      savedRange: null,
      form: {
        protocolType,
        protocolName,
@@ -97,6 +104,9 @@
        this.$nextTick(() => {
          if (this.$refs.editor) {
            this.$refs.editor.innerHTML = this.form.protocolContent
            this.savedRange = null
            this.ensureEditorBlock(false)
            this.updateEditorEmpty()
          }
        })
      })
@@ -107,8 +117,94 @@
        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()
    },
    // 目的:切换编辑器全屏状态,并在切换后保持编辑焦点。
@@ -139,7 +235,45 @@
    },
    // 目的:把编辑器 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() {
@@ -153,6 +287,7 @@
        this.$modal.msgError('请输入入园承诺内容')
        return
      }
      const submittedContent = this.form.protocolContent
      this.saving = true
      saveProtocolConfig({
        ...this.form,
@@ -161,10 +296,14 @@
        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
@@ -186,6 +325,13 @@
          }
        })
      })
      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,供纯文本粘贴时保留换行展示。