从车牌号格式、验证码等格式可知,如果用户输入的字符串存在不易发现的空格或者特殊字符,都会对程序的后续操作造成未知的影响,因此我们需要一个通用的组件在实现与正常输入框一致的交互性前提下,能够灵活限制用户在各个位置可供输入的字符,并具备良好的用户体验与较强的扩展性,为项目提供更丰富的功能,即本文所实现的单元格输入框组件,可用于车牌号输入框、验证码输入框等。
实现环境为vue + element ui + scss,如果项目没有集成scss,最快的的办法直接转为css即可,网上较多在线转换的站点如
最终组件效果实现如下
目录
Input Attributes
属性 | 说明 | 类型 | 默认值 | 是否必填 |
length | 单元格数量 | number | 是 | |
label | 输入框标签,不想要可为'' | string | 是 | |
valueName | 绑定的变量,需要在callBack输入回调时手动赋值 | string | 是 | |
required | 是否必填,单元格未填爆红样式 | boolean | false | |
disabled | 是否禁用交互 | boolean | false | |
keyPanel | 键位面板。除props属性名外,可以以键值的方式自定义字符面板,props属性是对应每个单元格索引所引用的字符面板限制输入,为空则代表索引下的单元格不限制输入。(下文有详解) | object |
Input Events
事件 | 说明 | 参数 |
callBack | 输入回调,需手动给valueName绑定的变量赋值,否则内容不会回显到单元格 | (value: string) |
validate | 填写完毕后的失焦校验 | (value: string) |
使用样例
<template>
<cell-input-field
:length="7"
label="校验号"
:required="true"
:valueName="cellValue"
@callBack="callBack"
@validate="validate"
:keyPanel="{
cn: ['你', '我', '他', '这', '就', '有', '了', '哦','的'],
zn: ['N', 'W', 'T', 'Z','J','Y', 'L', 'O', 'D'],
cd: ['啥', '点', '八'],
es: ['其', '他'],
props: ['zn', 'cn', {index: 1, values: ['这', '哦'], prop: 'cd', else: 'es'}]}"
></cell-input-field>
</template>
<script>
import CellInputField from "./CellInputField .vue";
export default {
components: { CellInputField },
data() { return { cellValue: '' } },
methods: {
callBack(val) { this.cellValue = val; },
validate(val) { console.log('填写完成,开始校验') }
}
}
</script>
<style scoped></style>
CellInputField.vue
<template>
<div class="box">
<div class="cell-label" @click="labelClick" style="cursor: pointer">{{ label }}</div>
<div class="cell-list">
<div
class="cell-item"
v-for="num in length"
:key="num"
:class="[num === step ? 'cell-item-active' : 'cell-item-inActive']"
ref="cellItems"
@mousedown="preventBlur($event)"
@click="clickCell(num)"
>
{{ charArr[num - 1] }}
</div>
</div>
<div class="box-right">
<el-button type="primary" slot="append" icon="el-icon-refresh" @mousedown="preventBlur($event)" @click="clearVal">重置</el-button>
<el-button type="primary" slot="append" icon="el-icon-refresh" @mousedown="preventBlur($event)" @click="clearVal">发送校验码</el-button>
</div>
<!-- 键位面板 -->
<div class="key-panel" v-if="keyPanel">
<div v-for="(list, key) in keyPanel" :key="key">
<section v-if="key != 'props'" class="key-list"
v-show="step > 0 && keyPanel.props && keyPanel.props[step - 1] &&
(key == keyPanel.props[step - 1] || (typeof keyPanel.props[step - 1] == 'object' && keyPanel.props[step - 1].values && (keyPanel.props[step - 1].values.includes(charArr[keyPanel.props[step - 1].index]) ? keyPanel.props[step - 1].prop : keyPanel.props[step - 1].else) == key))"
>
<div class="key-item"
v-for="(item, index) in list" :key="key + index"
:ref="key + index"
@click="pickOn(item)"
@mouseenter="hoverKeyItem(key, index)"
@mouseout="leaveKeyItem(key)"
@mousedown="preventBlur($event)">{{ item }}</div>
</section>
</div>
</div>
<el-input
ref="inputField"
style="position:absolute;opacity:0;width:300px!important;z-index:-2;"
v-model="val"
@input="inputChange"
@blur="inputBlur"
:disabled="disabled"
@keydown.native="keyDownEvent"
></el-input>
</div>
</template>
<script>
export default {
name: 'CellInputFiled',
props: {
label: {
type: String,
required: true
},
length: {
type: Number,
required: true
},
valueName: {
type: String,
required: true
},
required: {
type: Boolean,
default: () => {
return true
}
},
disabled: {
type: Boolean,
default: () => {
return false
}
},
keyPanel: {
type: Object,
default: null
}
},
data() {
return {
val: '',
charArr: [],
// 光标位
step: 0
}
},
watch: {
valueName: {
immediate: true, // 页面初始化时执行
handler(val) {
this.val = val
for (let i = val.length; i < this.length; i++) {
this.val += ' '
}
this.charArr = this.val.split('')
}
}
},
mounted() {},
methods: {
preventBlur(e) {
// 防止输入框失焦
e.preventDefault()
},
labelClick() {
console.log(this.val,this.charArr,this.step);
this.$refs.inputField.focus();
this.step = 1;
},
// 输入框输入事件
inputChange(_val) {
let step = this.step,
length = this.length,
val = this.val
// 退格删除操作
if (val.length < length) {
if (step > 0) {
this.charArr[step - 1] = ' '
this.val = this.charArr.join('')
this.$emit('callBack', this.val)
this.step -= 1
if (this.step < 1) this.step = 1
}
return
}
// 判断是否允许输入
if (this.keyPanel && this.keyPanel.props[step - 1]) {
return;
}
// 替换操作
this.val = val.substring(0, step - 1) + val.substring(length, length + 1) + val.substring(step, length)
this.$emit('callBack', this.val)
if (step < length) {
this.step += 1
} else if (this.val.indexOf(' ') >= 0) {
this.step = this.val.indexOf(' ') + 1
}
},
// 面板选取事件
pickOn(item) {
let step = this.step,
length = this.length
this.charArr[this.step - 1] = item
this.val = this.charArr.join('')
this.$emit('callBack', this.val)
if (step < length) {
this.step += 1
} else if (this.val.indexOf(' ') >= 0) {
this.step = this.val.indexOf(' ') + 1
}
},
leaveKeyItem(key) {
const keylist = this.keyPanel[key];
keylist.forEach((item, i) => {
this.$refs[key + i][0].classList.remove('adjacent-left','adjacent-right','adjacent-top','adjacent-bottom','adjacent-left-top','adjacent-right-top','adjacent-left-bottom','adjacent-right-bottom')
})
},
hoverKeyItem(key, index) {
const keylist = this.keyPanel[key];
let x = index % 6 , y = Math.floor(index / 6);
let lastY = Math.floor(keylist.length/6), lastX = keylist.length % 6 - 1;
keylist.forEach((item, i) => {
this.$refs[key + i][0].classList.remove('adjacent-left','adjacent-right','adjacent-top','adjacent-bottom','adjacent-left-top','adjacent-right-top','adjacent-left-bottom','adjacent-right-bottom')
})
if (x > 0) { // 左
this.$refs[key + (y*6+(x-1))][0].classList.add('adjacent-left');
if (y > 0) { // 左上
this.$refs[key + ((y-1)*6+(x-1))][0].classList.add('adjacent-left-top');
}
if (y < lastY - 1 || (y != lastY && x - 1 <= lastX)) { // 左下
this.$refs[key + ((y+1)*6+(x-1))][0].classList.add('adjacent-left-bottom');
}
}
if (x < 5) { // 右
if (y != lastY || x+1 <= lastX) {
this.$refs[key + (y*6+(x+1))][0].classList.add('adjacent-right');
}
if (y > 0) { // 右上
this.$refs[key + ((y-1)*6+(x+1))][0].classList.add('adjacent-right-top');
}
if (y < lastY - 1 || (y !=lastY && x + 1 <= lastX)) { // 右下
this.$refs[key + ((y+1)*6+(x+1))][0].classList.add('adjacent-right-bottom');
}
}
if (y > 0) { // 上
this.$refs[key + ((y-1)*6+x)][0].classList.add('adjacent-top');
}
if (y < lastY) { // 下
console.log(x,y)
if (y < lastY - 1 || x <= lastX) {
this.$refs[key + ((y+1)*6+x)][0].classList.add('adjacent-bottom');
}
}
},
// 键盘按下事件
keyDownEvent(event) {
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
this.step = 1;
break
case 'ArrowRight':
event.preventDefault();
this.step = Math.min(this.step + 1 , this.length);
break
case 'ArrowDown':
event.preventDefault();
this.step = this.length;
break
case 'ArrowLeft':
event.preventDefault();
this.step = Math.max(this.step - 1, 1);
break
default:
break
}
},
clickCell(step) {
this.step = step
this.$refs.inputField.focus()
},
// 重置
clearVal() {
this.$emit('callBack', '')
this.$refs.inputField.focus()
},
// 输入框失焦校验
inputBlur() {
let succ = true;
if (this.required) {
setTimeout(()=>{
this.$refs.cellItems.forEach((cellItem, index) => {
cellItem.classList.remove('cell-required');
if (!this.charArr[index] || this.charArr[index] === ' ') {
cellItem.classList.add('cell-required')
succ = false;
}
})
if (succ) {
this.$emit('validate', this.val);
}else {
this.$message.warning('请填写完整')
}
}, 100)
} else {
for (let index in this.charArr) {
if (!this.charArr[index] || this.charArr[index] === ' ') {
succ = false;
break;
}
}
if (succ) {
this.$emit('validate', this.val);
}else {
this.$message.warning('请填写完整')
}
}
this.step = 0;
},
}
}
</script>
<style lang="scss" scoped>
.box {
position: relative;
width: 100%;
display: flex;
gap: 5px;
align-items: center;
font-size: 18px;
.cell-label {
flex-shrink: 0;
padding: 0 5px;
}
.cell-list {
min-width: 250px;
display: flex;
flex-direction: row;
justify-content: start;
align-items: start;
flex-wrap: wrap;
padding-bottom: 2px;
.cell-item {
border: rgb(31, 31, 31) 1px solid;
padding: 5px;
text-align: center;
margin: 3px;
min-width: 40px;
min-height: 40px;
box-sizing: border-box;
border-radius: 5px;
cursor: pointer;
}
.cell-item-active {
/* x 偏移量 | y 偏移量 | 阴影模糊半径 | 阴影扩散半径 | 阴影颜色 */
box-shadow: 2px 2px 2px 1px rgba(79, 79, 80, 0.712);
-moz-box-shadow: 2px 2px 2px 1px rgba(79, 79, 80, 0.712);
-webkit-box-shadow: 2px 2px 2px 1px rgba(79, 79, 80, 0.712);
transform: scale(1.1);
transition: all 0.2s;
border: rgb(27, 149, 219) 1px solid;
}
.cell-item-inActive {
border: rgba(80, 79, 79, 0.2) 1px solid;
// cursor: not-allowed;
}
.cell-required {
border: solid red 1px !important;
}
}
.box-right {
display: flex;
flex-wrap: wrap;
flex-direction: row;
gap: 5px;
.el-button+.el-button {
margin-left: 0!important;
}
}
.key-panel {
position: absolute;
z-index: 2;
top: 100%;
width: 100%;
.key-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 0.3em;
background: rgba(36, 36, 36, 0.9);
border: 1px solid rgba(239, 239, 239);
max-width: calc(47px * 6 + 1.2em + 12px); // 盒子宽 + 外边距 + 边框宽
.key-item {
position: relative;
display: inline-block;
white-space: nowrap;
cursor: pointer;
color: rgb(239, 239, 239);
text-align: center;
box-sizing: border-box;
// border-radius: 5px;
min-width: 47px;
max-width: 47px;
padding: 0.3em;
font-size: 20px;
margin: 0.1em;
transition: all 0.3s;
font-weight: 500;
border: 2px solid transparent;
}
.key-item:hover {
border-color: rgb(239, 239, 239);
}
// border-image是css3属性所以存在兼容性问题,需要在属性前面设置-webkit等
.adjacent-left {
border-image: linear-gradient(to left, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-webkit-border-image: linear-gradient(to left, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-o-border-image: linear-gradient(to left, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-moz-border-image: linear-gradient(to left, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
border-image-slice: 10%;
}
.adjacent-right {
border-image: linear-gradient(to right, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-webkit-border-image: linear-gradient(to right, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-o-border-image: linear-gradient(to right, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-moz-border-image: linear-gradient(to bottom, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
border-image-slice: 10%;
}
.adjacent-top {
border-image: linear-gradient(to top, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-webkit-border-image: linear-gradient(to top, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-o-border-image: linear-gradient(to top, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-moz-border-image: linear-gradient(to bottom, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
border-image-slice: 10%;
}
.adjacent-bottom {
border-image: linear-gradient(to bottom, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-webkit-border-image: linear-gradient(to bottom, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-o-border-image: linear-gradient(to bottom, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
-moz-border-image: linear-gradient(to bottom, rgba(239, 239, 239, 0.4) 0%, rgba(239, 239, 239, 0.2) 75%, transparent 90%);
border-image-slice: 10%;
}
.adjacent-left-top {
border-image: linear-gradient(to left top, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-webkit-border-image: linear-gradient(to left top, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-o-border-image: linear-gradient(to left top, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-moz-border-image: linear-gradient(to bottom, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
border-image-slice: 10%;
}
.adjacent-right-top {
border-image: linear-gradient(to right top, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-webkit-border-image: linear-gradient(to right top, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-o-border-image: linear-gradient(to right top, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-moz-border-image: linear-gradient(to bottom, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
border-image-slice: 10%;
}
.adjacent-left-bottom {
border-image: linear-gradient(to left bottom, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-webkit-border-image: linear-gradient(to left bottom, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-o-border-image: linear-gradient(to left bottom, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-moz-border-image: linear-gradient(to bottom, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
border-image-slice: 10%;
}
.adjacent-right-bottom {
border-image: linear-gradient(to right bottom, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-webkit-border-image: linear-gradient(to right bottom, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-o-border-image: linear-gradient(to right bottom, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
-moz-border-image: linear-gradient(to bottom, rgba(239, 239, 239, 0.5) 0%, rgba(239, 239, 239, 0.1) 50%, transparent 80%);
border-image-slice: 10%;
}
}
}
}
</style>
知识点总结
box-shadow
属性值:X轴偏移量, Y轴偏移量, 阴影模糊半径, 阴影扩展半径, 阴影颜色, 投影方式
border-image
border-image允许开发者使用图片来定义边框,当然也可以如本人代码中使用渐变色属性值实现渐变色边框,渐变色属性值如下
linear-gradient(角度,开始颜色 百分比/尺寸, 结束颜色 百分比/尺寸)
如果需要使用图片,有以下子属性
border-image-source 素材路径
border-image-slice 四条横和竖切的切割尺寸(上、右、下、左)
border-image-width 边框素材的宽度,向内侧推进
border-image-outset 当前位置外推移距离(不影响盒模型)
border-image-repeat 重复形式, [stretch repeat round space]
mousedown
输入框聚焦期间,当点击div元素后不想让输入框失去焦点,我们可以在div的mousedown里阻止默认事件发生即可,因为元素失焦事件的发生是mousedown引起的
navtive
Element UI的el-input对原生标签input进行了封装,通过.navtive能在根元素上监听一个原生事件
watch
列表页中,假如某个组件位于弹窗内,点击列表项时使用v-show控制弹出窗体,在窗体内的该组件并不会执行mounted/created,因为在列表页渲染时就已经执行了
props: {
valueName: { type: 'string' }
},
mounted() {
// 无论如何都将输出undefined
console.log("valueName",this.valueName);
}
这时,我们使用watch监听组件中props属性值的变化,immediate参数表示初始绑定时是否应该立即执行,而不等待下一轮事件循环周期中执行,默认为false
watch: {
valueName: {
immediate: true,
handler(old, val) {}
}
}
一维数组作二维处理
1. 假设一维数组每6个元素为一行
2. 根据index可得二维位置 let x = index % 6 , let y = Math.floor(index / 6);
3. 而根据一维数组的长度len可得最后一个元素的二维位置 let lastY = Math.floor(len / 6), let lastX = len % 6 - 1;
keyPanel属性详解
:keyPanel="{
cn: ['你', '我', '他', '这', '就', '有', '了', '哦','的'],
zn: ['N', 'W', 'T', 'Z','J','Y', 'L', 'O', 'D'],
cd: ['啥', '点', '八'],
es: ['其', '他'],
props: ['zn', 'cn', {index: 1, values: ['这', '哦'], prop: 'cd', else: 'es'}]}"
props名是固定的,其他可以自定义键值代表字符面板名: 字符面板
props的值为数组,数组每个索引对应每个单元格。如props[1] == 'cn',代表第二个单元格的输入将会限制在名为cn的字符面板选择。
而props[3]对象是一个条件面板,表示如果索引为1的单元格选择了values的任意一个值时使用名为prop值的字符面板,否则使用名为prop值的字符面板。面板名为''或null代表输入无限制
单元格输入框的核心实现
1. 四个div加一个隐藏input域,点击div聚焦input供键盘输入
2. 每个单元格的内容缺省值为空格' '
3. input双向绑定的值长度从始至终保持与单元格长度一致(补空格),每输入一个字符就将单元格长度+1位置的值剪切替换val的step位置的字符
4. 如果是退格删除,input双向绑定的值长度将会小于单元格长度,在input事件即可使用该判断条件
遇到的问题(已处理)
当隐藏输入框的width: 0;height: 0;时,输入中文后在input事件开头输出val的值,整个值会变成单个输入的中文,原因不详。