You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fu-hsi-web/src/views/promptManagement/PromptConfig/add/PromptInput.vue

835 lines
25 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="custom-at-box">
<div class="custom-textarea-box">
<div
ref="customInput"
:class="[
'custom-textarea custom-scroll',
{ 'show-word-limit': showWordLimit && maxlength },
{ 'custom-textarea-disabled': disabled },
]"
:contenteditable="!disabled"
:placeholder="placeholder"
@input="onInput($event)"
@keydown="onKeyDownInput($event)"
@paste="onPaste($event)"
@copy="onCopy($event)"
@click="showList(false)"
/>
</div>
<div :key="`customInput${taskPanelIsInFullScreen}`">
<el-popover
ref="popoverRef"
v-model="showPopover"
trigger="click"
class="custom-select-box"
:append-to-body="taskPanelPopoverAppendToBody"
:style="{ top: popoverOffset + 'px' }"
@hide="hidePoppver"
>
<div
ref="customSelectContent"
class="custom-select-content custom-scroll"
>
<div
v-for="(item, index) in dataList"
:key="index"
:class="[
'custom-select-item',
{ hoverItem: selectedIndex === index },
]"
@click="handleClickOperatorItem(item)"
>
<div class="custom-select-item-content">
{{ item }}
</div>
</div>
</div>
</el-popover>
</div>
</div>
</template>
<script>
import Vue from 'vue'
export default {
props: {
// 输入框placeholder
placeholder: {
type: String,
default: '请输入...'
},
// 是否显示输入字数统计
showWordLimit: {
type: Boolean,
default: true
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 最大输入长度
maxlength: {
type: [Number, String],
default: '3000'
},
// 输入框高度
height: {
type: String,
default: '100px'
},
setRefresh: {
type: Object,
default: () => {}
},
dataList: {
type: Array,
default: () => []
},
// 输入框输入的内容
value: {
type: String,
default: ''
}
},
data() {
return {
// 已输入内容的长度
inputValueLen: 0,
top: '',
left: '',
message: '',
startOffset: 0,
// @搜索人dom
searSpan: null,
// 筛选人数据加载状态
searchOperatorLoad: false,
// @插入位置
selectionIndex: 0,
// 当前编辑的dom
dom: null,
// 当前编辑dom的index
domIndex: 0,
// 当前编辑dom的childNodes的index
childDomIndex: 0,
// 编辑前dom内容
beforeDomVal: '',
// 筛选人选择框
showPopover: false,
// 筛选人选择框偏移量
popoverOffset: 0,
listInput: false,
listInputValue: '',
// 防抖
timer: null,
// 保存弹窗加载状态
addDataLoad: false,
// 鼠标选择人的索引
selectedIndex: 0
}
},
computed: {
// 计算属性,用于同步父组件的数据
model: {
get() {
return this.value
},
set(newValue) {
this.$emit('input', newValue)
if (this.$refs.customInput) {
this.$emit('inputText', this.$refs.customInput.textContent)
}
const nodeList = this.$refs.customInput.childNodes
const list = []
nodeList.forEach((e) => {
if (e.childNodes) {
e.childNodes.forEach(i => {
if (i.className === 'active-text') {
list.push({
jobNumber: i.getAttribute('data-id'),
name: i.textContent.replace(/{/g, '').replace(/\s/g, '')
})
}
})
}
if (e.className === 'active-text') {
list.push({
jobNumber: e.getAttribute('data-id'),
name: e.textContent.replace(/{/g, '').replace(/\s/g, '')
})
}
})
this.$emit('changeChosen', list)
}
},
taskPanelIsInFullScreen() {
return this.$store.getters.taskPanelIsInFullScreen
},
taskPanelPopoverAppendToBody() {
return this.$store.getters.taskPanelPopoverAppendToBody
}
},
mounted() {
this.setNativeInputValue()
},
methods: {
// 设置输入框的值
setNativeInputValue() {
if (this.$refs.customInput) {
if (this.value === this.$refs.customInput.innerHTML) return
this.$refs.customInput.innerHTML = this.value
this.inputValueLen = this.$refs.customInput.innerText.length
}
},
// 筛选人弹窗数据选择
handleClickOperatorItem(item) {
this.addData(JSON.parse(JSON.stringify(item)))
this.$refs.customSelectContent.scrollTop = 0
this.selectedIndex = 0
this.showPopover = false
this.listInput = false
this.listInputValue = ''
},
// 艾特人弹窗关闭
hidePoppver() {
this.$refs.customSelectContent.scrollTop = 0
this.selectedIndex = 0
this.showPopover = false
this.listInput = false
this.listInputValue = ''
},
// 创建艾特需要插入的元素
createAtDom(item) {
// 先判断剩余输入长度是否能够完整插入元素
const dom = document.createElement('span')
dom.classList.add('active-text')
// 这里的contenteditable属性设置为false删除时可以整块删除
dom.setAttribute('contenteditable', 'false')
// 将id存储在dom元素的标签上便于后续数据处理
dom.setAttribute('data-id', item)
dom.innerHTML = `{${item}}&nbsp;`
return dom
},
// 插入元素
addData(item) {
const spanElement = this.createAtDom(item)
const maxlength = Number(this.maxlength) || 3000
// 因为插入后需要删除之前输入的@所以判断长度时需要减去这个1
if (maxlength - this.inputValueLen < spanElement.innerText.length - 1) {
this.$message('剩余字数不足')
return
}
this.$refs.customInput.focus()
// 获取当前光标位置的范围
const selection = window.getSelection()
const range = selection.getRangeAt(0)
// 找到要插入的节点
const nodes = Array.from(this.$refs.customInput.childNodes)
let insertNode = ''
// 是否是子元素
let domIsCustomInputChild = true
if (nodes[this.domIndex].nodeType === Node.TEXT_NODE) {
insertNode = nodes[this.domIndex]
} else {
const childNodeList = nodes[this.domIndex].childNodes
insertNode = childNodeList[this.childDomIndex]
domIsCustomInputChild = false
}
// 如果前一个节点是空的文本节点,@用户无法删除
// 添加判断条件:如果前一个节点是空的文本节点,则插入一个空的<span>节点
const html = insertNode.textContent
// 左边的节点
const textLeft = document.createTextNode(
html.substring(0, this.selectionIndex - 1) + ''
)
const emptySpan = document.createElement('span')
// 如果找到了要插入的节点,则在其前面插入新节点
if (insertNode) {
if (!textLeft.textContent) {
if (domIsCustomInputChild) {
this.$refs.customInput.insertBefore(emptySpan, insertNode)
} else {
nodes[this.domIndex].insertBefore(emptySpan, insertNode)
}
}
insertNode.parentNode.insertBefore(spanElement, insertNode.nextSibling)
// 删除多余的@以及搜索条件
const textContent = insertNode.textContent.slice(
0,
-(1 + this.listInputValue.length)
)
if (!textContent && insertNode.nodeName === '#text') {
insertNode.remove()
} else {
insertNode.textContent = textContent
}
} else {
// 如果未找到要插入的节点,则将新节点直接追加到末尾
this.$refs.customInput.appendChild(spanElement)
}
// 将光标移动到 span 元素之后
const nextNode = spanElement.nextSibling
range.setStart(
nextNode || spanElement.parentNode,
nextNode ? 0 : spanElement.parentNode.childNodes.length
)
range.setEnd(
nextNode || spanElement.parentNode,
nextNode ? 0 : spanElement.parentNode.childNodes.length
)
selection.removeAllRanges()
selection.addRange(range)
this.model = this.$refs.customInput.innerHTML
this.inputValueLen = this.$refs.customInput.innerText.length
this.showList(false)
},
// 检查是否发生了全选操作
isSelectAll() {
const selection = window.getSelection()
return selection.toString() === this.$refs.customInput.innerText
},
// 获取输入框是否选中文字
isSelect() {
try {
const selection = window.getSelection()
return selection.toString().length
} catch (error) {
return 0
}
},
// 输入事件
onKeyDownInput(event) {
// 获取当前输入框的长度
const currentLength = this.$refs.customInput.innerText.length
// 获取最大输入长度限制
const maxLength = Number(this.maxlength) || 3000
// 如果按下的键是非控制键并且当前长度已经达到了最大长度限制
if (currentLength >= maxLength) {
// 获取按键的 keyCode
var keyCode = event.keyCode || event.which
// 检查是否按下了 Ctrl 键
var ctrlKey = event.ctrlKey || event.metaKey // metaKey 用于 macOS 上的 Command 键
// 允许的按键Backspace8、Delete46、方向键和
var allowedKeys = [8, 46, 37, 38, 39, 40]
// 允许的按键 Ctrl+A、Ctrl+C、Ctrl+V
const allowedCtrlKey = [65, 67, 86]
// 检查按键是否在允许列表中并且没有执行选中操作
if (!allowedKeys.includes(keyCode) && !this.isSelect()) {
if ((allowedCtrlKey.includes(keyCode) && ctrlKey)) {
return
}
// 阻止默认行为
event.preventDefault()
return false
}
}
if (this.showPopover) {
const listElement = this.$refs.customSelectContent
const itemHeight = listElement.children[0].clientHeight
if (event.key === 'ArrowDown') {
// 防止光标移动
event.preventDefault()
// 移动选中索引
if (this.selectedIndex === this.dataList.length - 1) {
this.selectedIndex = 0 // 跳转到第一项
listElement.scrollTop = 0 // 滚动到列表顶部
} else {
this.selectedIndex++
const itemBottom = (this.selectedIndex + 1) * itemHeight
const scrollBottom = listElement.scrollTop + listElement.clientHeight
if (itemBottom > scrollBottom) {
listElement.scrollTop += itemHeight
}
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
if (this.selectedIndex === 0) {
this.selectedIndex = this.dataList.length - 1 // 跳转到最后一项
listElement.scrollTop = listElement.scrollHeight // 滚动到列表底部
} else {
this.selectedIndex--
const itemTop = this.selectedIndex * itemHeight
if (itemTop < listElement.scrollTop) {
listElement.scrollTop -= itemHeight
}
}
} else if (event.key === 'Enter') {
event.preventDefault()
if (!this.searchOperatorLoad) {
this.handleClickOperatorItem(
this.dataList[this.selectedIndex]
)
}
}
} else if (event.key === 'Backspace' && this.isSelectAll()) {
// 如果执行了全选操作并删除,清空输入框内容
this.$refs.customInput.innerText = ''
this.model = this.$refs.customInput.innerHTML
this.inputValueLen = 0
}
},
// 监听输入事件
onInput(e) {
this.inputValueLen = this.$refs.customInput.innerText.length
if (
['<div><br></div>', '<br>', '<span></span><br>'].includes(
this.$refs.customInput.innerHTML
)
) {
this.$refs.customInput.innerHTML = ''
this.inputValueLen = 0
} else if (e.data === '{') {
// 保存焦点位置
this.saveIndex()
this.showList()
this.listInput = true
} else if (this.showPopover) {
const { diffChars } = Vue.internal
const diffResult = diffChars(
this.beforeDomVal,
this.dom.textContent
)
let result = ''
// 遍历差异信息数组
for (let i = 0; i < diffResult.length; i++) {
const change = diffResult[i]
// 如果当前差异是添加或修改类型,则将其添加到结果字符串中
if (change.added) {
result += change.value
} else if (change.removed && change.value === '{') {
this.showList(false)
this.listInputValue = ''
}
}
if (this.timer) {
clearTimeout(this.timer)
}
this.listInputValue = result
this.timer = setTimeout(() => {
this.remoteMethod()
}, 300)
}
this.model = this.$refs.customInput.innerHTML
},
onPaste(event) {
event.preventDefault()
// 获取剪贴板中的 HTML 和文本内容
const html = (event.clipboardData || window.clipboardData).getData(
'text/html'
)
const text = (event.clipboardData || window.clipboardData).getData(
'text/plain'
)
// 设置最大输入限制
const maxLength = Number(this.maxlength) || 3000
// 此时加个条件 看鼠标选中的文本长度,剩余可输入长度加上选中文本长度
const selection1 = window.getSelection()
const range1 = selection1.getRangeAt(0)
const clonedSelection = range1.cloneContents()
let selectTextLen = 0
if (clonedSelection.textContent && clonedSelection.textContent.length) {
selectTextLen = clonedSelection.textContent.length
}
// 剩余可输入长度
const remainingLength = maxLength - this.inputValueLen + selectTextLen
// 过滤掉不可见字符
const cleanText = text.replace(/\s/g, '')
// 创建一个临时 div 用于处理粘贴的 HTML 内容
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
// 过滤掉不需要的内容,例如注释和换行符
const fragment = document.createDocumentFragment()
let totalLength = 0
if (cleanText) {
if (remainingLength >= cleanText.length) {
fragment.appendChild(document.createTextNode(cleanText))
} else {
const truncatedText = cleanText.substr(0, remainingLength)
fragment.appendChild(document.createTextNode(truncatedText))
}
} else {
Array.from(tempDiv.childNodes).forEach((node) => {
const regex = /<span class="active-text" contenteditable="false" data-id="(\d+)">{([^<]+)<\/span>/g
// 过滤注释和空白节点
if (
node.nodeType !== 8 &&
!(node.nodeType === 3 && !/\S/.test(node.textContent))
) {
const childText = node.textContent || ''
const childLength = childText.length
const childHtml = node.outerHTML || node.innerHTML
// 如果剩余空间足够,插入节点
if ((regex.exec(childHtml) !== null) && totalLength + childLength <= remainingLength) {
fragment.appendChild(node.cloneNode(true))
totalLength += childLength
} else if (remainingLength - totalLength > 0) {
// 如果还有剩余长度,不插入节点,插入文本内容
const lastNodeLength = remainingLength - totalLength
const truncatedText = childText.substr(0, lastNodeLength)
fragment.appendChild(document.createTextNode(truncatedText))
totalLength += truncatedText.length
} else {
// 如果添加当前节点的内容会超出剩余可插入长度,则结束循环
return
}
}
})
}
// 插入处理后的内容到光标位置
const selection = window.getSelection()
const range = selection.getRangeAt(0)
range.deleteContents()
range.insertNode(fragment)
// 更新输入框内容和长度
this.model = this.$refs.customInput.innerHTML
this.inputValueLen = this.$refs.customInput.innerText.length
// 设置光标位置为插入内容的后面一位
const newRange = document.createRange()
newRange.setStart(range.endContainer, range.endOffset)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
},
// 修改默认复制事件
onCopy(e) {
e.preventDefault()
const selection = window.getSelection()
const range = selection.getRangeAt(0)
const clonedSelection = range.cloneContents()
// 检查复制的内容是否包含符合条件的元素
const hasActiveText =
clonedSelection.querySelector(
'.active-text[contenteditable="false"][data-id]'
) !== null
const clipboardData = e.clipboardData || window.clipboardData
if (hasActiveText) {
const div = document.createElement('div')
div.appendChild(clonedSelection)
const selectedHtml = div.innerHTML
clipboardData.setData('text/html', selectedHtml)
} else {
clipboardData.setData('text/plain', clonedSelection.textContent || '')
}
},
// 保存焦点位置
async saveIndex() {
const selection = getSelection()
this.selectionIndex = selection.anchorOffset
const nodeList = this.$refs.customInput.childNodes
const range = selection.getRangeAt(0)
// 保存当前编辑的dom节点
for (const [index, value] of nodeList.entries()) {
// 这里第二个参数要配置成true没配置有其他的一些小bug
// (range.startContainer.contains(value) && range.endContainer.contains(value)) 是为了处理兼容性问题
if (
selection.containsNode(value, true) ||
(range.startContainer.contains(value) &&
range.endContainer.contains(value))
) {
if (value.nodeType === Node.TEXT_NODE) {
this.dom = value
this.beforeDomVal = value.textContent
this.domIndex = index
const selection = window.getSelection()
const range = selection.getRangeAt(0)
this.startOffset = range.startOffset - 1
} else {
const childNodeList = value.childNodes
for (const [childIndex, childValue] of childNodeList.entries()) {
if (selection.containsNode(childValue, true)) {
this.dom = value
this.beforeDomVal = value.textContent
this.domIndex = index
this.childDomIndex = childIndex
const selection = window.getSelection()
const range = selection.getRangeAt(0)
this.startOffset = range.startOffset - 1
}
}
}
}
}
},
// 筛选人弹窗
showList(bool = true) {
this.showPopover = bool
if (bool) {
const offset =
this.getCursorDistanceFromDivBottom(this.$refs.customInput) || -1
if (offset < 0) {
this.popoverOffset = 0
} else {
this.popoverOffset = -(offset - 1)
}
}
if (!bool) {
this.listInputValue = ''
this.remoteMethod()
}
},
// 获取光标位置
getCursorDistanceFromDivBottom(editableDiv) {
// 获取选区
const selection = window.getSelection()
// 获取选区的范围
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null
if (range) {
// 创建一个临时元素来标记范围的结束位置
const markerElement = document.createElement('span')
// 插入临时标记元素
range.insertNode(markerElement)
markerElement.appendChild(document.createTextNode('\u200B')) // 零宽空格
// 获取标记元素的位置信息
const markerOffsetTop = markerElement.offsetTop
const markerHeight = markerElement.offsetHeight
// 计算光标距离div底部的距离
const cursorDistanceFromBottom =
editableDiv.offsetHeight - (markerOffsetTop + markerHeight)
// 滚动条距顶部的高度
const scrollTop = editableDiv.scrollTop || 0
// 移除临时标记元素
markerElement.parentNode.removeChild(markerElement)
// 返回光标距离底部的距离
return cursorDistanceFromBottom + scrollTop
}
// 如果没有选区,则返回-1或者其他错误值
return -1
},
// 搜索筛选人
async remoteMethod() {
const query = this.listInputValue
this.searchOperatorLoad = true
this.searchOperatorLoad = false
},
handleNameShift(item) {
const name = item.realname || ''
if (!name) return '--'
if (name.length > 1) {
return name.slice(0, 1)
} else {
return name
}
},
// 按钮div点击 聚焦textarea
handleBtnBoxClick() {
this.$refs.customInput.focus()
},
getInnerText() {
const customInput = this.$refs.customInput
if (!customInput) return
return customInput.innerText
},
getJobId() {
const nodeList = this.$refs.customInput.childNodes
const list = []
nodeList.forEach((e) => {
if (e.className === 'active-text') {
list.push(e.getAttribute('data-id'))
}
})
return list
},
clearInput() {
this.$refs.customInput.innerText = ''
this.$refs.customInput.innerHTML = ''
this.inputValueLen = 0
this.$emit('input', '')
this.$emit('inputText', '')
this.$emit('changeChosen', [])
}
}
}
</script>
<style lang="scss" scoped>
.custom-textarea-btn {
position: absolute;
bottom: 1px;
right: 4px;
left: 4px;
text-align: right;
// background: #fff;
padding-bottom: 3px;
.el-button {
font-size: 12px;
padding: 4px 10px;
}
}
.custom-textarea-box {
position: relative;
}
.custom-at-limit {
position: absolute;
right: 12px;
bottom: 4px;
font-size: 12px;
color: #999;
line-height: 12px;
}
::v-deep.custom-textarea {
white-space: pre-wrap;
height: calc(100vh - 500px);
border: 1px solid #dcdfe6;
border-radius: 4px;
background-color: #ffffff;
padding: 5px 15px;
color: #606266;
overflow-y: auto;
line-height: 20px;
font-size: 14px;
transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
position: relative;
word-break: break-all;
&.show-word-limit {
padding-bottom: 16px;
}
&.custom-textarea-disabled {
cursor: not-allowed;
background-color: #f5f7fa;
border-color: #e4e7ed;
color: #c0c4cc;
}
&:focus {
border-color: #f98600 !important;
}
&:empty::before {
content: attr(placeholder);
font-size: 14px;
color: #c0c4cc;
}
.active-text {
color: #909399;
// padding: 2px 6px;
// background: #f4f4f5;
margin-right: 4px;
// border-radius: 4px;
// font-size: 12px;
}
// &:focus::before {
// content: "";
// }
}
::v-deep.custom-select-box {
position: relative;
.el-popover {
padding: 0;
top: 0;
box-shadow: 0 4px 8px 0 rgba(89, 88, 88, 0.8);
}
.custom-select-content {
width: 259px;
padding: 8px;
max-height: 260px;
overflow-y: auto;
}
.custom-select-item {
// font-size: 14px;
// padding: 0 20px;
// position: relative;
// height: 34px;
// line-height: 34px;
// box-sizing: border-box;
display: flex;
padding: 8px 12px;
border-bottom: 1px solid #ebebeb;
align-items: center;
color: #606266;
cursor: pointer;
&:last-child {
border-bottom: none;
}
.avatar-box {
flex-shrink: 0;
.custom-select-item-avatar {
width: 24px;
height: 24px;
background-color: #ffb803;
border-radius: 50%;
text-align: center;
line-height: 24px;
color: #ffffff;
}
}
.custom-select-item-content {
flex: 1;
padding-left: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
background-color: #f5f7fa;
}
&.hoverItem {
background-color: #dbdbdb;
}
}
.custom-select-empty {
padding: 10px 0;
text-align: center;
color: #999;
font-size: 14px;
&.load {
display: flex;
align-items: center;
justify-content: center;
}
}
.custom-scroll {
overflow: auto;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-thumb {
border-radius: 8px;
background-color: #b4b9bf;
}
}
}
</style>