306 lines
5.2 KiB
Plaintext
306 lines
5.2 KiB
Plaintext
<template>
|
|
<view
|
|
class="cl-input-otp"
|
|
:class="[
|
|
{
|
|
'cl-input-otp--disabled': disabled
|
|
},
|
|
pt.className
|
|
]"
|
|
>
|
|
<view class="cl-input-otp__inner">
|
|
<cl-input
|
|
v-model="value"
|
|
ref="inputRef"
|
|
:type="inputType"
|
|
:pt="{
|
|
className: '!h-full'
|
|
}"
|
|
:disabled="disabled"
|
|
:autofocus="autofocus"
|
|
:maxlength="length"
|
|
:hold-keyboard="false"
|
|
:clearable="false"
|
|
@change="onChange"
|
|
@focus="animateCursor(true)"
|
|
@blur="animateCursor(true)"
|
|
></cl-input>
|
|
</view>
|
|
|
|
<view class="cl-input-otp__list" :class="[pt.list?.className]">
|
|
<view
|
|
v-for="(item, index) in list"
|
|
:key="index"
|
|
class="cl-input-otp__item"
|
|
:class="[
|
|
{
|
|
'is-disabled': disabled,
|
|
'is-dark': isDark,
|
|
'is-active': value.length == index && isFocus
|
|
},
|
|
pt.item?.className
|
|
]"
|
|
>
|
|
<cl-text
|
|
:pt="{
|
|
className: pt.value?.className
|
|
}"
|
|
>{{ item }}</cl-text
|
|
>
|
|
<view
|
|
class="cl-input-otp__cursor"
|
|
:class="[pt.cursor?.className]"
|
|
:style="{
|
|
opacity: cursorOpacity
|
|
}"
|
|
v-if="value.length == index && isFocus && item == ''"
|
|
></view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch, type PropType, type Ref } from "vue";
|
|
import type { ClInputType, PassThroughProps } from "../../types";
|
|
import { isDark, parsePt } from "@/cool";
|
|
|
|
defineOptions({
|
|
name: "cl-input-otp"
|
|
});
|
|
|
|
// 定义组件属性
|
|
const props = defineProps({
|
|
// 透传样式
|
|
pt: {
|
|
type: Object,
|
|
default: () => ({})
|
|
},
|
|
// 绑定值
|
|
modelValue: {
|
|
type: String,
|
|
default: ""
|
|
},
|
|
// 是否自动聚焦
|
|
autofocus: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
// 验证码位数
|
|
length: {
|
|
type: Number,
|
|
default: 4
|
|
},
|
|
// 是否禁用
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
// 输入框类型
|
|
inputType: {
|
|
type: String as PropType<ClInputType>,
|
|
default: "number"
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 事件定义
|
|
* update:modelValue - 更新绑定值
|
|
* done - 输入完成
|
|
*/
|
|
const emit = defineEmits(["update:modelValue", "done"]);
|
|
|
|
/**
|
|
* 透传样式类型定义
|
|
*/
|
|
type PassThrough = {
|
|
className?: string;
|
|
list?: PassThroughProps;
|
|
item?: PassThroughProps;
|
|
cursor?: PassThroughProps;
|
|
value?: PassThroughProps;
|
|
};
|
|
|
|
// 解析透传样式
|
|
const pt = computed(() => parsePt<PassThrough>(props.pt));
|
|
|
|
// 输入框引用
|
|
const inputRef = ref<ClInputComponentPublicInstance | null>(null);
|
|
|
|
// 输入值
|
|
const value = ref(props.modelValue) as Ref<string>;
|
|
|
|
/**
|
|
* 是否聚焦状态
|
|
*/
|
|
const isFocus = computed<boolean>(() => {
|
|
if (props.disabled) {
|
|
return false;
|
|
}
|
|
|
|
if (inputRef.value == null) {
|
|
return false;
|
|
}
|
|
|
|
return (inputRef.value as ClInputComponentPublicInstance).isFocus;
|
|
});
|
|
|
|
/**
|
|
* 验证码数组
|
|
* 根据长度生成空数组,每个位置填充对应的输入值
|
|
*/
|
|
const list = computed<string[]>(() => {
|
|
const arr = [] as string[];
|
|
|
|
for (let i = 0; i < props.length; i++) {
|
|
arr.push(value.value.charAt(i));
|
|
}
|
|
|
|
return arr;
|
|
});
|
|
|
|
/**
|
|
* 监听绑定值变化
|
|
* 同步更新内部值
|
|
*/
|
|
watch(
|
|
computed(() => props.modelValue),
|
|
(val: string) => {
|
|
value.value = val;
|
|
}
|
|
);
|
|
|
|
/**
|
|
* 输入事件处理
|
|
* @param val 输入值
|
|
*/
|
|
function onChange(val: string) {
|
|
// 更新绑定值
|
|
emit("update:modelValue", val);
|
|
|
|
// 输入完成时触发done事件
|
|
if (val.length == props.length) {
|
|
uni.hideKeyboard();
|
|
emit("done", val);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 光标闪烁透明度值
|
|
* 范围: 0.3-1.0
|
|
*/
|
|
const cursorOpacity = ref(0.3);
|
|
|
|
/**
|
|
* 光标闪烁动画帧ID
|
|
*/
|
|
let cursorAnimationId = 0;
|
|
|
|
/**
|
|
* 控制光标闪烁动画
|
|
* @param isIncreasing 透明度是否递增
|
|
*/
|
|
function animateCursor(isIncreasing: boolean) {
|
|
// #ifdef APP
|
|
// 未获得焦点时不执行动画
|
|
if (!isFocus.value) {
|
|
return;
|
|
}
|
|
|
|
// 取消上一次动画
|
|
if (cursorAnimationId != 0) {
|
|
cancelAnimationFrame(cursorAnimationId);
|
|
cursorAnimationId = 0;
|
|
}
|
|
|
|
// 执行动画帧
|
|
cursorAnimationId = requestAnimationFrame(() => {
|
|
// 根据方向调整透明度值
|
|
cursorOpacity.value += isIncreasing ? 0.01 : -0.01;
|
|
|
|
// 到达边界值时改变方向
|
|
if (cursorOpacity.value > 1) {
|
|
animateCursor(false);
|
|
} else if (cursorOpacity.value <= 0.3) {
|
|
animateCursor(true);
|
|
} else {
|
|
animateCursor(isIncreasing);
|
|
}
|
|
});
|
|
// #endif
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.cl-input-otp {
|
|
@apply relative overflow-visible;
|
|
|
|
&__inner {
|
|
@apply absolute top-0 h-full z-10;
|
|
opacity: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
&__list {
|
|
@apply flex flex-row relative;
|
|
margin: 0 -10rpx;
|
|
}
|
|
|
|
&__item {
|
|
@apply flex flex-row items-center justify-center;
|
|
@apply border border-solid border-surface-200 rounded-lg bg-white;
|
|
height: 80rpx;
|
|
width: 80rpx;
|
|
margin: 0 10rpx;
|
|
|
|
&.is-disabled {
|
|
@apply bg-surface-100 opacity-70;
|
|
}
|
|
|
|
&.is-dark {
|
|
@apply bg-surface-800 border-surface-600;
|
|
|
|
&.is-disabled {
|
|
@apply bg-surface-700;
|
|
}
|
|
}
|
|
|
|
&.is-active {
|
|
@apply border-primary-500;
|
|
}
|
|
}
|
|
|
|
&__cursor {
|
|
@apply absolute;
|
|
@apply bg-primary-500;
|
|
width: 2rpx;
|
|
height: 36rpx;
|
|
}
|
|
|
|
// #ifdef H5 || MP
|
|
&__cursor {
|
|
animation: flash 1s infinite ease;
|
|
}
|
|
|
|
@keyframes flash {
|
|
0% {
|
|
opacity: 0.3;
|
|
}
|
|
|
|
50% {
|
|
opacity: 1;
|
|
}
|
|
|
|
100% {
|
|
opacity: 0.3;
|
|
}
|
|
}
|
|
// #endif
|
|
|
|
&--disabled {
|
|
@apply opacity-50;
|
|
}
|
|
}
|
|
</style>
|