模板字符vue3版本

99 阅读6分钟

因业务需求开发仿豆包模板字符串,记录开发过程,
父组件调用updateParts获取模板组件数据,具体代码如下

image.png

<!-- 获取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>