因业务需求开发仿豆包模板字符串,记录开发过程,
父组件调用updateParts获取模板组件数据,具体代码如下
<!-- 获取parts 数据(目前初始数据为固定) -->
<template>
<div class="editor-container">
<div
ref="editorRef"
class="content-editor"
contenteditable
@input="handleInput"
@keydown="handleKeyDown"
@blur="handleClick"
>
<template v-for="(part, index) in parts" :key="part.id">
<!-- 普通文本 -->
<span
v-if="part.type === 'text'"
class="static-text"
:data-id="part.id"
:data-type="part.type"
>{{ part.content }}</span>
<!-- 可编辑文本 -->
<span
v-else-if="part.type === 'editable'"
class="editable-text"
:data-id="part.id"
:data-type="part.type"
contenteditable="true"
@keydown.stop
@click.stop
@input="(e) => handleEditableInput(e, part)"
>{{ part.content }}</span>
<!-- 下拉选择框 -->
<span
v-else-if="part.type === 'select'"
class="select-wrapper"
:data-id="part.id"
:data-type="part.type"
contenteditable="false"
>
<a-select
v-model:value="part.value"
class="inline-select"
:options="part.options"
:tabindex="-1"
@keydown.stop
@click.stop
@change="(value) => handleSelectChange(value, part)"
/>
</span>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { watch } from 'vue';
const props = defineProps({
initialParts: {
type: Array,
required: true
}
});
const editorRef = ref(null);
const emit = defineEmits(['updateParts', 'change']);
// 定义内容结构
const parts = ref([]);
// 在组件挂载后调用接口获取数据
onMounted(async () => {
const data = [
{ id: 1, type: 'text', content: '我希望你是一位文案助理,帮我生成一篇' },
{
id: 2,
type: 'select',
value: '文章',
options: [
{ value: '文章', label: '文章' },
{ value: '视频', label: '视频' },
{ value: '教程教程教程', label: '教程教程教程' }
]
},
{ id: 3, type: 'text', content: ',请以 ' },
{
id: 4,
type: 'editable',
content: '主题'
},
{ id: 5, type: 'text', content: ' ,为主题,包含 ' },
{
id: 6,
type: 'editable',
content: '内容1'
},
{ id: 7, type: 'text', content: '等内容,要求逻辑清晰,分条表述,风格正式严谨,文章篇幅' },
{
id: 8,
type: 'editable',
content: '字数'
},
{ id: 9, type: 'text', content: '字' },
{ id: 10, type: 'text', content: '。' }
];
parts.value = data;
});
// 监听 parts 数组的变化
watch(parts, (newParts) => {
emit('updateParts', newParts);
// 触发change事件,将完整的parts数据传递给父组件
emit('change', {
parts: newParts,
text: generateFullText(newParts)
});
console.log(newParts)
}, { deep: true });
// 生成完整文本
const generateFullText = (parts) => {
return parts.map(part => {
if(part.type === 'select') return part.value;
return part.content;
}).join('');
};
// 处理可编辑文本的输入
const handleEditableInput = (e, part) => {
part.content = e.target.textContent;
};
// 处理下拉选择的变化
const handleSelectChange = (value, part) => {
part.value = value;
};
// 修改处理键盘事件
const handleKeyDown = (e) => {
const selection = window.getSelection();
const range = selection.getRangeCount > 0 ? selection.getRangeAt(0) : null;
if (!range) return;
let currentElement = range.startContainer;
if (currentElement.nodeType === Node.TEXT_NODE) {
currentElement = currentElement.parentElement;
}
if (currentElement.classList.contains('select-wrapper')) {
e.preventDefault();
return;
}
if (e.key === 'Backspace') {
if (range.startOffset === 0) {
const prevElement = currentElement.previousElementSibling;
if (prevElement) {
e.preventDefault();
const id = parseInt(prevElement.getAttribute('data-id'));
const index = parts.value.findIndex(p => p.id === id);
if (index !== -1) {
parts.value = [
...parts.value.slice(0, index),
...parts.value.slice(index + 1)
];
}
}
}
}
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
const target = e.target;
if (target.classList.contains('select-wrapper') ||
target.closest('.select-wrapper')) {
e.preventDefault();
}
}
};
const handleInput = () => {
const selection = window.getSelection();
const range = selection.getRangeCount > 0 ? selection.getRangeAt(0) : null;
if (!range) return;
let currentElement = range.startContainer;
if (currentElement.nodeType === Node.TEXT_NODE) {
currentElement = currentElement.parentElement;
}
const id = parseInt(currentElement.getAttribute('data-id'));
const part = parts.value.find(p => p.id === id);
if (part && part.type === 'editable') {
part.content = currentElement.textContent;
}
};
const handleClick = (e) => {
// const target = e.target;
// console.log(target)
// console.log(target.innerHTML)
// const children = target.innerHTML.children;
const children = editorRef.value.children;
for (let i = 0; i < children.length; i++) {
const id = parseInt(children[i].getAttribute('data-id'));
const type = children[i].getAttribute('data-type');
let content = '';
if (children[i].classList.contains('editable-text')) {
content = children[i].textContent;
} else if (children[i].classList.contains('select-wrapper')) {
const select = children[i].querySelector('.ant-select');
if (select) {
content = select.textContent;
}
} else if (children[i].classList.contains('static-text')) {
content = children[i].textContent;
}
const partIndex = parts.value.findIndex(p => p.id === id);
if (partIndex !== -1) {
parts.value[partIndex] = {
...parts.value[partIndex],
content: content,
type: type
};
}
}
// 触发更新
emit('updateParts', [...parts.value]);
console.log('updateParts:', parts.value);
}
</script>
<style scoped>
.editor-container {
padding: 16px;
}
.content-editor {
line-height: 1.8;
}
.static-text {
color: #666;
}
.editable-text {
color: #c20000;
border-bottom: 1px solid #c20000;
padding: 0 4px;
min-width: 20px;
display: inline-block;
}
.select-wrapper {
display: inline-block;
vertical-align: middle;
margin: 0 4px;
user-select: none;
cursor: pointer;
}
.inline-select {
/* width: 100px; */
pointer-events: all;
}
:deep(.ant-select-selector) {
border: none !important;
background: #ffe6ea !important;
cursor: pointer !important;
height: 25px !important;
}
:deep(.ant-select-dropdown .ant-select-item-option-selected:not(.ant-select-item-option-disabled)) {
color: #c20000;
font-weight: 600;
background-color: #FFF3F3 !important;
}
:deep(.ant-select-dropdown .ant-select-dropdown-placement-bottomLeft) {
width: 100%;
}
:deep(.ant-select) {
cursor: pointer !important;
}
:deep(.ant-select-selection-item) {
color:#c20000;
font-size: 13px;
line-height: 25px !important;
}
</style>`
从上层获取数据 修复之前删除数据被赋值的bug
<!-- 获取parts 数据(目前初始数据为固定) -->
<template>
<div class="editor-container">
<div ref="editorRef" class="content-editor" contenteditable @input="handleInput" @keydown="handleKeyDown"
@blur="handleClick">
<template v-for="(part, index) in parts" :key="part.id">
<!-- 普通文本 -->
<span v-if="part.type === 'text'" class="static-text" :data-id="part.id"
:data-type="part.type">{{ part.content }}</span>
<!-- 可编辑文本 -->
<span v-else-if="part.type === 'editable'" class="editable-text" :data-id="part.id" :data-type="part.type"
contenteditable="true" @keydown.stop @click.stop
@input="(e) => handleEditableInput(e, part)">{{ part.content }}</span>
<!-- 下拉选择框 -->
<span v-else-if="part.type === 'select'" class="select-wrapper" :data-id="part.id" :data-type="part.type"
contenteditable="false">
<a-select v-model:value="part.value" class="inline-select" :options="part.options" :tabindex="-1"
@keydown.stop @click.stop @change="(value) => handleSelectChange(value, part)" />
</span>
</template>
</div>
</div>
</template>
<script setup>
import {
ref,
onMounted,
defineExpose
} from 'vue';
import {
watch
} from 'vue';
import $ from 'jquery'
const props = defineProps({
data: {
type: Array,
// required: true
}
});
const editorRef = ref(null);
const emit = defineEmits(['updateParts', 'change']);
// 定义内容结构
const parts = ref([]);
defineExpose({
parts,
getParts: () => {
return parts.value
}
})
// 在组件挂载后调用接口获取数据
onMounted(async () => {
// const data = [
// { id: 1, type: 'text', content: '我希望你是一位文案助理,帮我生成一篇' },
// {
// id: 2,
// type: 'select',
// value: '文章',
// options: [
// { value: '文章', label: '文章' },
// { value: '视频', label: '视频' },
// { value: '教程教程教程教程', label: '教程教程教程教程' }
// ]
// },
// { id: 3, type: 'text', content: ',请以 ' },
// {
// id: 4,
// type: 'editable',
// content: '主题'
// },
// { id: 5, type: 'text', content: ' ,为主题,包含 ' },
// {
// id: 6,
// type: 'editable',
// content: '内容1'
// },
// { id: 7, type: 'text', content: '等内容,要求逻辑清晰,分条表述,风格正式严谨,文章篇幅' },
// {
// id: 8,
// type: 'editable',
// content: '字数'
// },
// { id: 9, type: 'text', content: '字' },
// { id: 10, type: 'text', content: '。' }
// ];
// parts.value = data;
parts.value = props.data
console.log('parts', parts.value)
});
// 监听 parts 数组的变化
watch(parts, (newParts) => {
emit('updateParts', newParts);
// 触发change事件,将完整的parts数据传递给父组件
emit('change', {
parts: newParts,
text: generateFullText(newParts)
});
console.log(newParts)
}, {
deep: true
});
watch(() => props.data, (newParts) => {
parts.value = props.data
}, {
deep: true
});
// 生成完整文本
const generateFullText = (parts) => {
if (!parts) return ''
return parts.map(part => {
if (part.type === 'select') return part.value;
return part.content;
}).join('');
};
// 处理可编辑文本的输入
const handleEditableInput = (e, part) => {
debugger
part.content = e.target.textContent;
};
// 处理下拉选择的变化
const handleSelectChange = (value, part) => {
part.value = value;
};
// 修改处理键盘事件
const handleKeyDown = (e) => {
const selection = window.getSelection();
const range = selection.getRangeCount > 0 ? selection.getRangeAt(0) : null;
if (!range) return;
let currentElement = range.startContainer;
if (currentElement.nodeType === Node.TEXT_NODE) {
currentElement = currentElement.parentElement;
}
if (currentElement.classList.contains('select-wrapper')) {
e.preventDefault();
return;
}
if (e.key === 'Backspace') {
// 检查是否在editable元素内
const editableElement = currentElement.closest('.editable-text');
if (editableElement) {
const id = parseInt(editableElement.getAttribute('data-id'));
const index = parts.value.findIndex(p => p.id === id);
if (index !== -1) {
// 如果内容为空,保持为空,不重置为默认值
if (!editableElement.textContent) {
e.preventDefault();
parts.value[index] = {
...parts.value[index],
content: ''
};
// 强制更新DOM
editableElement.textContent = '';
// 强制触发更新
emit('updateParts', [...parts.value]);
emit('change', {
parts: parts.value,
text: generateFullText(parts.value)
});
}
}
} else if (range.startOffset === 0) {
const prevElement = currentElement.previousElementSibling;
if (prevElement) {
e.preventDefault();
const id = parseInt(prevElement.getAttribute('data-id'));
const index = parts.value.findIndex(p => p.id === id);
if (index !== -1) {
// 如果是editable类型,保持为空,不重置为默认值
if (parts.value[index].type === 'editable') {
parts.value[index] = {
...parts.value[index],
content: ''
};
// 强制更新DOM
prevElement.textContent = '';
// 强制触发更新
emit('updateParts', [...parts.value]);
emit('change', {
parts: parts.value,
text: generateFullText(parts.value)
});
} else {
parts.value = [
...parts.value.slice(0, index),
...parts.value.slice(index + 1)
];
}
}
}
}
}
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
const target = e.target;
if (target.classList.contains('select-wrapper') ||
target.closest('.select-wrapper')) {
e.preventDefault();
}
}
};
const handleInput = () => {
const selection = window.getSelection();
const range = selection.getRangeCount > 0 ? selection.getRangeAt(0) : null;
if (!range) return;
let currentElement = range.startContainer;
if (currentElement.nodeType === Node.TEXT_NODE) {
currentElement = currentElement.parentElement;
}
const id = parseInt(currentElement.getAttribute('data-id'));
const part = parts.value.find(p => p.id === id);
if (part && part.type === 'editable') {
part.content = currentElement.textContent;
}
};
const handleClick = (e) => {
// const target = e.target;
// console.log(target)
// console.log(target.innerHTML)
// const children = target.innerHTML.children;
const children = editorRef.value.children;
for (let i = 0; i < children.length; i++) {
console.info(children[i].getAttribute('data-id'))
if (children[i].getAttribute('data-id') != null) {
const id = parseInt(children[i].getAttribute('data-id'));
const type = children[i].getAttribute('data-type');
let content = '';
if (children[i].classList.contains('editable-text')) {
content = children[i].textContent;
} else if (children[i].classList.contains('select-wrapper')) {
const select = children[i].querySelector('.ant-select');
if (select) {
content = select.textContent;
}
} else if (children[i].classList.contains('static-text')) {
content = children[i].textContent;
}
const partIndex = parts.value.findIndex(p => p.id === id);
if (partIndex !== -1) {
parts.value[partIndex] = {
...parts.value[partIndex],
content: content,
type: type
};
}
}else{
parts.value=[{
type:'static-text',
content:children[i].textContent
}]
}
}
// 触发更新
emit('updateParts', [...parts.value]);
}
</script>
<style scoped>
.editor-container {
padding: 16px;
}
.content-editor {
line-height: 1.8;
outline: none;
}
.static-text {
color: #666;
}
.editable-text {
color: #c20000;
border-bottom: 1px solid #c20000;
padding: 0 4px;
min-width: 20px;
display: inline-block;
}
.select-wrapper {
display: inline-block;
vertical-align: middle;
margin: 0 4px;
user-select: none;
cursor: pointer;
}
:deep(.ant-select-selector) {
border: none !important;
background: #ffe6ea !important;
cursor: pointer !important;
height: 25px !important;
}
:deep(.ant-select-dropdown .ant-select-item-option-selected:not(.ant-select-item-option-disabled)) {
color: #c20000;
font-weight: 600;
background-color: #FFF3F3 !important;
}
:deep(.ant-select-dropdown .ant-select-dropdown-placement-bottomLeft) {
width: 100%;
}
:deep(.ant-select) {
cursor: pointer !important;
}
:deep(.ant-select-selection-item) {
color: #c20000;
font-size: 13px;
line-height: 25px !important;
}
</style>