一、需求描述
最近经常使用Trae生成一些小组件和功能代码(对Trae赶兴趣的可以看之前的文章《TraeAi上手体验》),刚好在用uniapp开发微信小程序时需要开发一个输入密码的弹框组件,于是想用Trae来实现。原型设计稿如下:
二、Trae生成的雏形组件
经过一次描述附加原型设计图后,Trae给我生成了初始代码,预览如下:
在微信开发者工具中使用似乎是可以的,它还细心的额外给我增加了对确认按钮的激活条件(多选框需要至少选一个),这个激活条件我是没有体现在需求描述上的,但实际也是需要的。
然而,在实际使用的时候,会发现以下问题:
①它在界面上使用了4个input输入框,当输入框为空时再按下backspace键,无法触发@input或者@keyup,导致无法将光标回退到上一个输入框。同时,因为使用了4个不同的input输入框,导致在输入后光标自动移动到下一个输入框时会引起输入法键盘的闪动。
②input上虽然设置了type=“number”,但在微信开发者工具中,还是能输入其它字符。
③虽然它在弹窗关闭时对所有输入变量进行了重置,但再次打开弹框时,多选的checkbox组件依然显示之前的勾选状态,而没有被重新初始化为未勾选状态。
④需求描述和原型设计稿中都有右上角关闭的描述,但实际没有显示弹窗的右上角的关闭按钮(其实代码是有生成,但它给的关闭图片并不存在,图片路径却不是随机的,而似乎有根据项目使用的cdn来生成,所以差点让我信以为真)
三、问题修复
上面4个问题中,第一个问题是最主要的,也是比较麻烦的。
在保留4个input输入框的方式下,最初为了解决当输入框为空时再按下backspace键,无法触发@input的问题时,Trae想到了插入零宽字符\u200B
的方案:
但在实际测试过程中,会导致输入的数字无法正常显示。
经过几次尝试,如果在不改变实现方式,也就是保留4个input输入框的方式下,无法解决事件触发及输入法键盘闪动的问题。于是Trae给出来用“4个普通view元素+隐藏的input”的方式来实现密码输入:
主要改动说明:
1. 移除了多个输入框,改用一个隐藏的真实输入框
2. 添加了显示用的密码框
3. 简化了输入处理逻辑
4. 在关闭弹窗时增加了状态重置
5. 优化了样式结构,确保隐藏输入框覆盖整个输入区域
经过上述修改,输入法闪动的问题解决了,backspace回退的问题也解决了。但是引入了一个新的问题:
由于真实的输入框是覆盖在上层,虽然它也设置了opacity: 0;
,在微信开发者工具中没有发现什么问题,但是在Android真机上却显示了输入框的文字和闪烁的光标。
输入框文字可以用css样式进行隐藏,但光标却始终无法隐藏:
Trae给出的几次方案:
方案一:
.real-password-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100rpx;
opacity: 0;
z-index: 1;
background: transparent;
color: transparent;
caret-color: transparent;
}
方案二:
.real-password-input {
pointer-events: none; /* 添加这行 */
}
方案三:
.real-password-input {
position: absolute;
top: -9999rpx; // 将输入框移出可视区域
left: -9999rpx;
width: 100%;
height: 100rpx;
opacity: 0;
z-index: 1;
background: transparent;
color: transparent;
}
当然还有deepseek给出的unselectable=“on” ,readonly(微信小程序input没有这个属性),disabled,这些方案都不行。
最终为了不让光标显示出来,那么只能采用方案三,但方案三有个问题,就是如果输入完成,输入法的键盘消失后,如果想再次输入该怎么办?能否再次点击那4个输入框来唤起输入法的键盘呢?
一般我们使用input来获取焦点是这样的input.focus()
,但在uniapp微信小程序的开发中,实际上发现这样是无效的。当然同时也发现了,在弹框出现后如果想让input自动获得焦点,也同样不能使用这样的方式,哪怕你设置了setTimeout延时:
好在Trae最终给出了另一个方案:
通过focus
的设置,解决了在弹窗显示时自动获取焦点的问题。那么对于点击密码输入框自动获取焦点的解决是否也可以通过改变focus
属性的方式实现呢?答案是可以的,只不过这时候我们加上了setTimeout的包裹:
const handlePasswordInputClick = () => {
isFocus.value = false
setTimeout(() => {
isFocus.value = true
}, 100)
}
第一个问题终于解决,剩下的三个问题中的第三个问题checkbox组件勾选状态重置问题,Trae最终也给出了解决办法:
最终得到的完整代码如下:
<template>
<uni-popup ref="popup" type="center" :mask-click="false" @change="handlePopupChange">
<view class="share-popup">
<view class="share-popup-header">
<text class="share-popup-title">分享xx记录</text>
<uni-icons type="closeempty" size="20" color="#118170" class="popup-detail-header__close"
@click="handleClose" />
</view>
<view class="share-content">
<view class="share-tip">选择分享内容,并设置查看密码后,对方通过输入密码就能查看到患者分享的记录内容了~</view>
<view class="share-options">
<checkbox-group @change="handleShareOptionChange">
<label v-for="(item, index) in shareOptions" :key="item.value" class="share-option-item">
<checkbox
:value="item.value"
color="#00D997"
:checked="checkedStatus[index]"
/>
<text>{{item.label}}</text>
</label>
</checkbox-group>
</view>
<view class="password-section">
<view class="password-title">输入密码</view>
<view class="password-input-group" @click="handlePasswordGroupClick">
<view
v-for="(item, index) in 4"
:key="index"
class="password-input"
@click="handlePasswordInputClick"
>
{{ password[index] }}
</view>
<input
type="number"
maxlength="4"
v-model="realPassword"
class="real-password-input"
@input="handlePasswordInput"
ref="passwordInput"
:focus="isFocus"
/>
</view>
</view>
<button
class="confirm-btn"
:class="{'confirm-btn-active': isValid}"
:disabled="!isValid"
@click="handleConfirm"
>
确认
</button>
</view>
</view>
</uni-popup>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
const popup = ref(null)
const password = ref(['','','',''])
const realPassword = ref('')
const selectedOptions = ref([])
const passwordInput = ref(null)
const isFocus = ref(false)
const checkedStatus = ref(new Array(3).fill(false))
const shareOptions = [
{ label: '选项1, value: '1' },
{ label: '选项2', value: '2' },
{ label: '选项3', value: '3' }
]
const isValid = computed(() => {
return password.value.every(v => v !== '') && selectedOptions.value.length > 0
})
const handlePasswordInput = (e) => {
const value = e.detail.value
if (!/^\d*$/.test(value)) {
nextTick(() => {
realPassword.value = realPassword.value.replace(/\D/g, '')
})
return
}
const valueArray = value.split('')
password.value = new Array(4).fill('').map((_, index) => valueArray[index] || '')
// 当输入满4位数时,自动失去焦点
if (value.length === 4) {
isFocus.value = false
}
}
const handlePasswordGroupClick = () => {
const input = passwordInput.value
if (input) {
input.focus()
}
}
const handleShareOptionChange = (e) => {
selectedOptions.value = e.detail.value
checkedStatus.value = shareOptions.map(option => selectedOptions.value.includes(option.value))
}
const handleConfirm = () => {
popup.value.close()
}
const handleClose = () => {
popup.value.close()
}
const resetState = () => {
password.value = ['','','','']
realPassword.value = ''
selectedOptions.value = []
checkedStatus.value = new Array(3).fill(false)
}
const handlePopupChange = (e) => {
isFocus.value = e.show
if (!e.show) {
resetState()
}
}
const show = () => {
resetState()
nextTick(() => {
popup.value.open()
})
}
const handlePasswordInputClick = () => {
isFocus.value = false
setTimeout(() => {
isFocus.value = true
}, 100)
}
defineExpose({
show
})
</script>
<style lang="scss" scoped>
.share-popup {
width: 622rpx;
background: #FFFFFF;
border-radius: 24rpx;
padding: 40rpx;
&-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
}
&-title {
font-size: 36rpx;
font-weight: 500;
color: #1A1A1A;
}
&-close {
width: 48rpx;
height: 48rpx;
}
}
.share-content {
.share-tip {
font-size: 26rpx;
color: #999999;
line-height: 37rpx;
margin-bottom: 30rpx;
}
}
.share-options {
margin-bottom: 40rpx;
.share-option-item {
display: flex;
align-items: center;
margin-bottom: 20rpx;
font-size: 30rpx;
color: #1A1A1A;
}
}
.password-section {
.password-title {
font-size: 30rpx;
color: #1A1A1A;
margin-bottom: 20rpx;
}
.password-input-group {
display: flex;
justify-content: space-between;
margin-bottom: 40rpx;
position: relative;
}
.password-input {
width: 100rpx;
height: 100rpx;
background: #F5F5F5;
border-radius: 12rpx;
text-align: center;
font-size: 36rpx;
line-height: 100rpx;
cursor: pointer;
}
.real-password-input {
position: absolute;
top: -9999px;
left: -9999px;
width: 1rpx;
height: 1rpx;
opacity: 0;
z-index: 1;
background: transparent;
color: transparent;
caret-color: transparent;
}
}
.confirm-btn {
width: 100%;
height: 88rpx;
background: #CCCCCC;
border-radius: 44rpx;
color: #FFFFFF;
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
&-active {
background: linear-gradient(132deg, #00D997 0%, #00D57D 100%);
}
}
</style>
四、后续
上面我们通过改变focus属性的方式解决了在弹窗显示时自动获取焦点的问题,但后面我们发现这种方式能否生效与弹窗显示的方式有关:
①当用户通过点击的交互方式触发显示弹框时,通过改变focus属性的方式可以让input自动获得焦点,在移动端表现为输入法的软键盘被唤起,在微信开发者工具中表现为直接可以输入数字显示到输入框中。
②当弹框是通过脚本唤起,而非用户交互的结果时,通过改变focus属性的方式,在开发者工具中无法自动获得焦点,但在移动端可以自动获得焦点,不过对focus属性的改变最好是在弹框显示的回调里通过setTimeout进行设置,否则可能会无法生效(比如在iphone上会出现):
const handlePopupChange = (e) => {
setTimeout(() => {
isFocus.value = e.show
}, 100)
}