基于 vue3,实现短信模板编辑

前言

一般在后台管理系统中,短信是一个常用功能,不同业务场景需要发送不同的信息,这就需要维护针对不同场景的短信模板,你可以在第三方短信平台维护,也可以在自己后台维护一套,在后台维护可以打通场景参数,使用起来更加方便,下面我们就来简单实现一下

效果图

这是项目中的实现效果,我们简化一下实现效果

按照下面方式实现

大致交互为

  1. 选择业务场景
  2. 短信模板内输入短信信息,短信内的动态内容,插入对应场景的参数,右侧为实时预览效果
  3. 可以在短信模板部分编辑内容,也可以点击业务参数插入到内容内

实现

难点在于短信模板实现,既支持文字输入,又支持自定义样式,一般input元素无法实现,可以通过设置元素 css user-modify 属性,不过这个已经停止维护,官方不建议使用。建议使用contenteditable

短信模板内需要对业务参数做特殊显示,如果选中场景后,直接输出一段普通文字,是无法达到对场景特殊显示的,这就需要特定的数据结构,例如

javascript 复制代码
const scenes = ref([
  { label: '用户注册短信验证码', value: ['短信验证码'], content: '尊敬的用户您好,您正在注册账号,验证码为 {1},请勿泄露给他人', params: ['短信验证码'] },
  { label: '修改绑定手机号', value: ['短信验证码', '时间'], content: '尊敬的用户您好,您正在变更手机号,验证码为 {1} ,请勿泄露给他人,有效期 {2} 分钟。', params: ['短信验证码', '时间'] },
])

label是场景名称,value是场景可选参数,content是场景默认内容,里面大括号带索引的字符,跟params中对应索引使用到的参数对应,这样就可以知道短信模板内哪些文字需要特别处理

基础页面

简易实现短信配置页面,下拉框选择业务场景,带出默认模板在下方显示,可以看到当前业务场景下的业务参数,右边显示最终结果

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vue 3 示例</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <style>
      .container {
        margin: 50px auto;
        padding: 20px;
        display: flex;
      }
      .left {
        width: 50%;
      }
      .form-group {
        margin-bottom: 20px;
      }
      .message-group {
        font-size: 14px;
        padding: 0 5px 5px;
        border: 1px solid rgba(0, 0, 0, 0.15);
        flex: 1;
      }
      .params {
        display: flex;
        flex: 1;
        align-items: center;
        justify-content: flex-start;
        cursor: pointer;
        flex-wrap: wrap;
      }
      .param {
        word-break: keep-all;
        margin-right: 25px;
        color: #1890ff;
        border: 1px dashed #1890ff;
        border-radius: 4px;
        padding: 2px 5px;
        span {
          padding-right: 5px;
        }
      }
      .line {
        width: 100%;
        height: 1px;
        background-color: #ddd;
        margin: 10px 0;
      }
      .form-template {
        width: 100%;
        min-height: 100px;
        box-sizing: border-box;
        font-size: 14px;
        padding: 10px;
        line-height: 1.5;
        word-break: break-word;
        resize: vertical;
        overflow: auto;
      }
      label {
        display: block;
        margin-bottom: 5px;
      }
      select,
      input {
        width: 100%;
        padding: 8px;
        border: 1px solid #ddd;
        border-radius: 4px;
        box-sizing: border-box;
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="container">
        <div class="left">
          <div class="form-group">
            <label>业务场景:</label>
            <select v-model="selectedTemplate">
              <option value="" disabled hidden>请选择业务场景</option>
              <option v-for="scene in scenes" :key="scene.value" :value="scene">{{ scene.label }}</option>
            </select>
          </div>

          <div class="form-group message-group">
            <label>短信模板:</label>
            <div class="params">
              业务参数:
              <div class="param" v-for="param in selectedTemplate.value" :key="param">{{ param }}</div>
            </div>
            <div class="line"></div>
            <div class="form-template" contenteditable="true" type="text"></div>
          </div>
        </div>
        <div class="right">
          <div>结果:</div>
          <div>{{ messageResult }}</div>
        </div>
      </div>
    </div>

    <script>
      const { createApp, ref } = Vue

      createApp({
        setup() {
          const scenes = ref([
            {
              label: '用户注册短信验证码',
              value: ['短信验证码'],
              content: '尊敬的用户您好,您正在注册账号,验证码为 {1},请勿泄露给他人,有效期5分钟',
              params: ['短信验证码'],
            },
            {
              label: '修改绑定手机号',
              value: ['短信验证码', '时间'],
              content: '尊敬的用户您好,您正在变更手机号,验证码为 {1} ,请勿泄露给他人,有效期 {2} 分钟。',
              params: ['短信验证码', '时间'],
            },
          ])
          const selectedTemplate = ref('')
          const messageResult = ref('')

          return {
            scenes,
            selectedTemplate,
            messageResult,
          }
        },
      }).mount('#app')
    </script>
  </body>
</html>

短信模板替换参数

select添加选中事件,当选中场景后,我们需要把场景默认短信内容显示出来,并将特定参数做特别显示

通过插入自定义元素smstag实现短信参数特别显示,同时也要设定该元素contentEditable属性禁止修改

javascript 复制代码
// select 选择事件
function handleChange(value) {
  messageRef.value.innerHTML = replaceTemplateParams()
}

// 替换模板参数
function replaceTemplateParams() {
  const regx = /\{(.*?)\}/g
  let tempContent = selectedTemplate.value.content
  return tempContent.replace(regx, (match) => {
    let tempValue = ''
    let index = parseInt(match.replace(/\{|\}/g, ''))
    let tempParam = ''
    tempParam = selectedTemplate.value.params[index - 1]

    let node = document.createElement('smstag')
    node.contentEditable = 'false'
    node.innerText = tempParam
    console.log(node.outerHTML)
    return node.outerHTML
  })
}

如何在右侧正常显示短信内容呢,可以在替换模板参数时候,替换占位符为正常参数内容,笔者通过匹配smstag属性拿到文字实现

javascript 复制代码
// select 选择事件
function handleChange(value) {
  messageRef.value.innerHTML = replaceTemplateParams()
  getMessageContent(messageRef.value.innerHTML)
}


// message 内容
function getMessageContent(content) {
  let temp = ''
  const regex = /<smstag.*?>(.*?)<\/smstag>/g
  temp = content.replace(regex, (result) => {
    return `【${result}】`
  })
  messageResult.value = temp
}

点击参数插入模板

默认显示可以了,那么接下来实现点击参数时,根据光标位置插入参数,重点就是对光标处理

  1. 监听光标事件,当位于短信模板区域时候,记录位置
javascript 复制代码
// 光标位置
const savedRange = ref(null)

function selectionChangeFn() {
  let sel = window.getSelection()
  let range = sel.rangeCount > 0 ? sel.getRangeAt(0) : null
  if (range && range.commonAncestorContainer.ownerDocument.activeElement.id === 'messageId') {
    savedRange.value = range
  }
}

onMounted(() => {
  document.addEventListener('selectionchange', selectionChangeFn)
})
onUnmounted(() => {
  document.removeEventListener('selectionchange', selectionChangeFn)
})
  1. 监听参数点击事件,先生成内容,后插入到短信光标位置
javascript 复制代码
// 生成插入内容
function insertStr(str) {
  let node = document.createElement('smstag')
  node.innerText = str
  node.contentEditable = 'false'
  insertNode(node)
}

// 插入到光标位置
function insertNode(node) {
  // 删除选中内容
  savedRange.value && savedRange.value.deleteContents()
  // 插入标签
  savedRange.value && savedRange.value.insertNode(node)
  // 插入成功,光标定位到标签后面
  savedRange.value && savedRange.value.setStartAfter(node.lastChild)
  getMessageContent(messageRef.value.innerHTML)
  // 光标置空
  savedRange.value = null
}

优化

这样我们基本上就实现了短信模板的编辑功能,使用过程中发现还可以做点优化

  1. 当光标挨着smstag标签时候,难以察觉到光标的存在,可以在自定义标签尾部插入空格
javascript 复制代码
let space = document.createTextNode('\u00A0')
node.appendChild(space)
  1. 自定义标签尾部添加删除 'X' 标志,交互更加便利
javascript 复制代码
// 添加删除按钮
let deleteBtn = document.createElement('span')
deleteBtn.innerText = 'x'
// 删除按钮添加类名
deleteBtn.className = 'deleteBtn'
node.appendChild(deleteBtn)

添加删除点击事件

javascript 复制代码
function handleClick(e) {
  if (e.target.nodeName === 'SPAN') {
    e.target.parentNode.remove()
    getMessageContent(messageRef.value.innerHTML)
  }
}

最终代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vue 3 示例</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <style>
      .container {
        margin: 50px auto;
        padding: 20px;
        display: flex;
      }
      .left {
        width: 50%;
      }
      .form-group {
        margin-bottom: 20px;
      }
      .message-group {
        font-size: 14px;
        padding: 0 5px 5px;
        border: 1px solid rgba(0, 0, 0, 0.15);
        flex: 1;
      }
      .params {
        display: flex;
        flex: 1;
        align-items: center;
        justify-content: flex-start;
        cursor: pointer;
        flex-wrap: wrap;
      }
      .param {
        word-break: keep-all;
        margin-right: 25px;
        color: #1890ff;
        border: 1px dashed #1890ff;
        border-radius: 4px;
        padding: 2px 5px;
        span {
          padding-right: 5px;
        }
      }
      .line {
        width: 100%;
        height: 1px;
        background-color: #ddd;
        margin: 10px 0;
      }
      .form-template {
        width: 100%;
        min-height: 100px;
        box-sizing: border-box;
        font-size: 14px;
        padding: 10px;
        line-height: 1.5;
        word-break: break-word;
        resize: vertical;
        overflow: auto;
      }
      smstag {
        color: #1890ff;
        border-radius: 4px;
        padding: 1px 1px 1px 8px;
        border: 1px solid #1890ff;
        white-space: nowrap;
        margin: 0 3px;
        font-weight: 400;
        cursor: default;
        font-size: 14px;
      }
      .deleteBtn {
        color: #1890ff;
        cursor: pointer;
        margin-left: 5px;
        margin-right: 0px;
        font-size: 14px;
        height: 27px;
        display: inline-block;
        vertical-align: middle;
      }
      label {
        display: block;
        margin-bottom: 5px;
      }
      select,
      input {
        width: 100%;
        padding: 8px;
        border: 1px solid #ddd;
        border-radius: 4px;
        box-sizing: border-box;
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="container">
        <div class="left">
          <div class="form-group">
            <label>业务场景:</label>
            <select v-model="selectedTemplate" @change="handleChange">
              <option value="" disabled hidden>请选择业务场景</option>
              <option v-for="scene in scenes" :key="scene.value" :value="scene">{{ scene.label }}</option>
            </select>
          </div>

          <div class="form-group message-group">
            <label>短信模板:</label>
            <div class="params">
              业务参数:
              <div class="param" v-for="param in selectedTemplate.value" :key="param" @click="insertStr(param)">
                {{ param }}
              </div>
            </div>
            <div class="line"></div>
            <div
              id="messageId"
              class="form-template"
              contenteditable="true"
              type="text"
              ref="messageRef"
              @click="handleClick"
              ></div>
          </div>
        </div>
        <div class="right">
          <div>结果:</div>
          <div>{{ messageResult }}</div>
        </div>
      </div>
    </div>

    <script>
      const { createApp, ref, watch, onMounted, onUnmounted } = Vue

      createApp({
        setup() {
          const scenes = ref([
            {
              label: '用户注册短信验证码',
              value: ['短信验证码'],
              content: '尊敬的用户您好,您正在注册账号,验证码为 {1},请勿泄露给他人,有效期5分钟',
              params: ['短信验证码'],
            },
            {
              label: '修改绑定手机号',
              value: ['短信验证码', '时间'],
              content: '尊敬的用户您好,您正在变更手机号,验证码为 {1} ,请勿泄露给他人,有效期 {2} 分钟。',
              params: ['短信验证码', '时间'],
            },
          ])
          const selectedTemplate = ref('')
          const messageRef = ref(null)
          const messageResult = ref('')
          // 光标位置
          const savedRange = ref(null)

          // select 选择事件
          function handleChange(value) {
            messageRef.value.innerHTML = replaceTemplateParams()
            getMessageContent(messageRef.value.innerHTML)
          }

          // 替换模板参数
          function replaceTemplateParams() {
            const regx = /\{(.*?)\}/g
            let tempContent = selectedTemplate.value.content
            return tempContent.replace(regx, (match) => {
              let tempValue = ''
              let index = parseInt(match.replace(/\{|\}/g, ''))
              let tempParam = ''
              tempParam = selectedTemplate.value.params[index - 1]

              let node = document.createElement('smstag')
              node.contentEditable = 'false'
              // 添加删除按钮
              let deleteBtn = document.createElement('span')
              deleteBtn.innerText = 'x'
              // 删除按钮添加类名
              deleteBtn.className = 'deleteBtn'
              node.innerText = tempParam
              node.appendChild(deleteBtn)
              let space = document.createTextNode('\u00A0')
              node.appendChild(space)
              return node.outerHTML
            })
          }

          // message 内容
          function getMessageContent(content) {
            let result = ''
            const regex = /<smstag.*?>(.*?)<\/smstag>/g
            result = content.replace(regex, (match, result) => {
              let matchStr = result.replace(/<\/?span.*?>|x|&nbsp;/g, '')
              return `【${matchStr}】`
            })
            messageResult.value = result
          }

          function handleClick(e) {
            if (e.target.nodeName === 'SPAN') {
              e.target.parentNode.remove()
              getMessageContent(messageRef.value.innerHTML)
            }
          }

          // 生成插入内容
          function insertStr(str) {
            let node = document.createElement('smstag')
            node.innerText = str
            node.contentEditable = 'false'
            // 添加删除按钮
            let deleteBtn = document.createElement('span')
            deleteBtn.innerText = 'x'
            // 删除按钮添加类名
            deleteBtn.className = 'deleteBtn'
            node.appendChild(deleteBtn)
            let space = document.createTextNode('\u00A0')
            node.appendChild(space)
            insertNode(node)
          }

          // 插入到光标位置
          function insertNode(node) {
            // 删除选中内容
            savedRange.value && savedRange.value.deleteContents()
            // 插入标签
            savedRange.value && savedRange.value.insertNode(node)
            // 插入成功,光标定位到标签后面
            savedRange.value && savedRange.value.setStartAfter(node.lastChild)
            getMessageContent(messageRef.value.innerHTML)
            // 光标置空
            savedRange.value = null
          }

          function selectionChangeFn() {
            let sel = window.getSelection()
            let range = sel.rangeCount > 0 ? sel.getRangeAt(0) : null
            if (range && range.commonAncestorContainer.ownerDocument.activeElement.id === 'messageId') {
              savedRange.value = range
            }
          }

          onMounted(() => {
            document.addEventListener('selectionchange', selectionChangeFn)
          })
          onUnmounted(() => {
            document.removeEventListener('selectionchange', selectionChangeFn)
          })
          return {
            scenes,
            selectedTemplate,
            messageRef,
            messageResult,
            handleChange,
            insertStr,
            handleClick,
          }
        },
      }).mount('#app')
    </script>
  </body>
</html>
相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax