<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 => ({
|
'&': '&',
|
'<': '<',
|
'>': '>',
|
'"': '"',
|
"'": '''
|
}[char]))
|
}
|
}
|
}
|
</script>
|