基于 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>
相关推荐
sxf3593 分钟前
vue项目调用netcore webapi接口提示:400 Bad Request的解决
java·前端·vue.js
咪库咪库咪31 分钟前
CSS过渡与动画
前端
前端康师傅35 分钟前
CSS基础教程-性能优化
前端·css
掘金用户897239 分钟前
微信小程序 扫码+拍照
前端
一颗奇趣蛋40 分钟前
input输入框输入数字之后展示千分位(财务系统专用)
javascript·vue.js
睡不着的可乐40 分钟前
Ant Design Vue 表格复杂数据合并单元格
前端·vue.js·ant design
upsilon40 分钟前
React-router V7(配置路由)
前端·react.js
江城开朗的豌豆42 分钟前
手把手教你定制Vue3项目的Git提交规范:cz-customizable实战
前端·git·程序员
不简说43 分钟前
sv-print可视化打印组件不完全指南④
前端·javascript·vue.js
Chaoran44 分钟前
vue 插槽的使用和插槽的本质
前端·vue.js·typescript