zjk
2026-07-31 8665daa7aa863f96d0d8f27c167edae1efc38022
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
<template>
  <main class="protocol-page">
    <section class="protocol-card">
      <h1>入园承诺</h1>
      <div class="section-title">
        <span></span>
        <strong>信息配置</strong>
      </div>
 
      <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="加粗" @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="请输入"
            @focus="handleEditorFocus"
            @input="handleEditorInput"
            @mouseup="saveSelection"
            @keyup="saveSelection"
            @keydown.enter="handleEditorEnter"
            @paste="handlePaste"
          ></div>
        </section>
 
        <footer v-if="canEditProtocol" class="protocol-actions">
          <button class="protocol-save-button" type="submit" :disabled="saving">{{ saving ? '保存中' : '保存' }}</button>
        </footer>
      </form>
    </section>
  </main>
</template>
 
<script>
import { getProtocolConfig, saveProtocolConfig } from '@/api/visitor-admin/config'
import { getResponseData, normalizeBoolean } from '@/utils/visitor-admin'
 
const protocolType = 'ENTRY_COMMITMENT'
const protocolName = '入园承诺'
 
export default {
  name: 'VisitorProtocolConfig',
  // 目的:初始化入园承诺编辑状态、全屏状态和协议表单默认值。
  data() {
    return {
      saving: false,
      fullscreen: false,
      editorEmpty: true,
      savedRange: null,
      form: {
        protocolType,
        protocolName,
        protocolContent: '',
        enabled: true
      }
    }
  },
  computed: {
    // 目的:同时检查中台只读状态和编辑权限,控制协议编辑入口。
    canEditProtocol() {
      return !this.isMiddlePlatformReadonly && this.$auth.hasPermi('visitor:protocol:edit')
    }
  },
  // 目的:页面创建时加载当前协议配置。
  created() {
    this.getDetail()
  },
  // 目的:挂载全局按键监听,用于全屏编辑时响应 Esc。
  mounted() {
    document.addEventListener('keydown', this.handleKeydown)
  },
  // 目的:组件销毁前移除按键监听,避免路由切换后重复触发。
  beforeDestroy() {
    document.removeEventListener('keydown', this.handleKeydown)
  },
  methods: {
    // 目的:加载协议详情,并把后端 HTML 内容清洗后写入编辑器。
    getDetail() {
      getProtocolConfig({ protocolType: this.form.protocolType }).then(res => {
        const data = getResponseData(res) || {}
        this.form = {
          ...this.form,
          ...data,
          protocolType,
          protocolName,
          enabled: data.enabled === undefined ? true : normalizeBoolean(data.enabled),
          protocolContent: this.sanitizeHtml(data.protocolContent || '')
        }
        this.$nextTick(() => {
          if (this.$refs.editor) {
            this.$refs.editor.innerHTML = this.form.protocolContent
            this.savedRange = null
            this.ensureEditorBlock(false)
            this.updateEditorEmpty()
          }
        })
      })
    },
    // 目的:执行富文本命令;中台只读或无权限时禁止修改内容。
    exec(command, value) {
      if (!this.canEditProtocol) {
        this.denyMiddlePlatformWrite()
        return
      }
      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()
    },
    // 目的:切换编辑器全屏状态,并在切换后保持编辑焦点。
    toggleFullscreen() {
      this.fullscreen = !this.fullscreen
      this.$nextTick(() => {
        this.$refs.editor && this.$refs.editor.focus()
      })
    },
    // 目的:监听 Esc 退出协议编辑器全屏状态。
    handleKeydown(event) {
      if (event.key === 'Escape' && this.fullscreen) {
        this.fullscreen = false
      }
    },
    // 目的:粘贴内容前清理 HTML,避免脚本和事件属性进入协议正文。
    handlePaste(event) {
      if (!this.canEditProtocol) {
        event.preventDefault()
        this.denyMiddlePlatformWrite()
        return
      }
      event.preventDefault()
      const html = event.clipboardData.getData('text/html')
      const text = event.clipboardData.getData('text/plain')
      const content = html ? this.sanitizeHtml(html) : this.escapeHtml(text).replace(/\n/g, '<br>')
      this.exec('insertHTML', content)
    },
    // 目的:把编辑器 DOM 内容同步回表单,并再次清洗 HTML。
    syncEditor() {
      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() {
      if (!this.canEditProtocol) {
        this.denyMiddlePlatformWrite()
        return
      }
      this.syncEditor()
      const text = this.$refs.editor ? this.$refs.editor.textContent.trim() : ''
      if (!this.form.protocolContent || !text) {
        this.$modal.msgError('请输入入园承诺内容')
        return
      }
      const submittedContent = this.form.protocolContent
      this.saving = true
      saveProtocolConfig({
        ...this.form,
        protocolType,
        protocolName,
        enabled: true
      }).then(res => {
        const data = getResponseData(res) || {}
        this.form = {
          ...this.form,
          ...data,
          protocolType,
          protocolName,
          protocolContent: submittedContent
        }
        this.updateEditorEmpty()
        this.$modal.msgSuccess('保存成功')
      }).finally(() => {
        this.saving = false
      })
    },
    // 目的:清理协议 HTML 中的危险标签和事件属性,降低富文本 XSS 风险。
    sanitizeHtml(html) {
      const template = document.createElement('template')
      template.innerHTML = String(html || '')
      template.content.querySelectorAll('script, style, iframe, object, embed, link, meta').forEach(node => {
        node.parentNode.removeChild(node)
      })
      template.content.querySelectorAll('*').forEach(node => {
        Array.prototype.slice.call(node.attributes).forEach(attribute => {
          const name = attribute.name.toLowerCase()
          const value = attribute.value || ''
          if (name.indexOf('on') === 0 || /javascript:/i.test(value)) {
            node.removeAttribute(attribute.name)
          }
        })
      })
      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,供纯文本粘贴时保留换行展示。
    escapeHtml(value) {
      return String(value == null ? '' : value).replace(/[&<>"']/g, char => ({
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#39;'
      }[char]))
    }
  }
}
</script>