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

按照下面方式实现

大致交互为
- 选择业务场景
- 短信模板内输入短信信息,短信内的动态内容,插入对应场景的参数,右侧为实时预览效果
- 可以在短信模板部分编辑内容,也可以点击业务参数插入到内容内
实现
难点在于短信模板实现,既支持文字输入,又支持自定义样式,一般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
}
点击参数插入模板
默认显示可以了,那么接下来实现点击参数时,根据光标位置插入参数,重点就是对光标处理
- 监听光标事件,当位于短信模板区域时候,记录位置
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)
})
- 监听参数点击事件,先生成内容,后插入到短信光标位置
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
}
优化
这样我们基本上就实现了短信模板的编辑功能,使用过程中发现还可以做点优化
- 当光标挨着
smstag
标签时候,难以察觉到光标的存在,可以在自定义标签尾部插入空格
javascript
let space = document.createTextNode('\u00A0')
node.appendChild(space)
- 自定义标签尾部添加删除 '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| /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>