245 lines
6.5 KiB
Vue
245 lines
6.5 KiB
Vue
<template>
|
||
<view :class="rootClass" :style="rootStyle">
|
||
<!-- 背景提示文字 -->
|
||
<view class="wd-slide-verify__text">
|
||
<slot name="text">
|
||
<text class="wd-slide-verify__text-inner">
|
||
{{ slideVerifyText }}
|
||
</text>
|
||
</slot>
|
||
</view>
|
||
|
||
<!-- 滑过区域 -->
|
||
<view class="wd-slide-verify__track" :style="trackStyle">
|
||
<view class="wd-slide-verify__track-text">
|
||
<slot name="success-text">
|
||
<text class="wd-slide-verify__track-text--success">
|
||
{{ slideVerifySuccessText }}
|
||
</text>
|
||
</slot>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 滑块 -->
|
||
<view
|
||
class="wd-slide-verify__button"
|
||
@touchstart.prevent="onTouchStart"
|
||
@touchmove.prevent="onTouchMove"
|
||
@touchend="onTouchEnd"
|
||
:style="buttonStyle"
|
||
>
|
||
<slot v-if="isPass" name="success-icon">
|
||
<view
|
||
class="wd-slide-verify__button-icon--success"
|
||
:style="{
|
||
backgroundColor: activeBackgroundColor
|
||
}"
|
||
>
|
||
<wd-icon :name="successIcon" :size="successIconSize" color="#fff" />
|
||
</view>
|
||
</slot>
|
||
|
||
<slot v-else name="icon">
|
||
<view class="wd-slide-verify__button-icon">
|
||
<wd-icon :name="icon" :size="iconSize" />
|
||
</view>
|
||
</slot>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
export default {
|
||
name: 'wd-slide-verify',
|
||
options: {
|
||
addGlobalClass: true,
|
||
virtualHost: true,
|
||
styleIsolation: 'shared'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, computed, onBeforeUnmount, type CSSProperties } from 'vue'
|
||
import wdIcon from '../wd-icon/wd-icon.vue'
|
||
import { slideVerifyProps, type SlideVerifyExpose } from './types'
|
||
import { useTouch } from '../composables/useTouch'
|
||
import { useTranslate } from '../composables/useTranslate'
|
||
import { objToStyle, addUnit, isDef } from '../common/util'
|
||
|
||
const props = defineProps(slideVerifyProps)
|
||
const emit = defineEmits(['success', 'fail'])
|
||
|
||
const touch = useTouch()
|
||
const { translate } = useTranslate('slideVerify')
|
||
|
||
const slideVerifyText = computed(() => {
|
||
return isDef(props.text) && props.text !== '' ? props.text : translate('text')
|
||
})
|
||
|
||
const slideVerifySuccessText = computed(() => {
|
||
return isDef(props.successText) && props.successText !== '' ? props.successText : translate('successText')
|
||
})
|
||
|
||
const rootClass = computed(() => {
|
||
return [
|
||
'wd-slide-verify',
|
||
{
|
||
'is-disabled': props.disabled,
|
||
'is-success': isPass.value,
|
||
'is-dragging': isDragging.value
|
||
},
|
||
props.customClass
|
||
]
|
||
})
|
||
|
||
const rootStyle = computed(() => {
|
||
const style: CSSProperties = {
|
||
width: addUnit(props.width),
|
||
height: addUnit(props.height),
|
||
backgroundColor: props.backgroundColor
|
||
}
|
||
|
||
return `${objToStyle(style)}${props.customStyle}`
|
||
})
|
||
|
||
const buttonStyle = computed(() => {
|
||
const size = props.height
|
||
const style: CSSProperties = {
|
||
width: addUnit(size),
|
||
height: addUnit(size),
|
||
transform: `translate(${currentPosition.value}px, 0)`,
|
||
transition: isResetting.value ? 'all 0.3s ease' : 'none',
|
||
'--wd-slide-verify-button-size': addUnit(size)
|
||
}
|
||
return objToStyle(style)
|
||
})
|
||
|
||
const trackStyle = computed(() => {
|
||
const style: CSSProperties = {
|
||
width: `${currentPosition.value}px`,
|
||
background: props.activeBackgroundColor,
|
||
'--wot-slide-verify-track-width': addUnit(props.width)
|
||
}
|
||
return objToStyle(style)
|
||
})
|
||
|
||
/**
|
||
* 从字符串或数字中解析出有效的数字。
|
||
*
|
||
* - 对于 number 类型,直接返回原值(可能包括 Infinity、-Infinity)。
|
||
* - 对于 string 类型,使用 `parseFloat` 解析前缀中的数字。
|
||
* - 当无法解析出有效数字(结果为 NaN)时,返回 0 作为安全默认值。
|
||
*
|
||
* 注意:后续使用该函数的逻辑(如 `maxPosition` 计算)会额外通过 `isFinite`
|
||
* 等判断过滤掉 Infinity / 非法值,因此这里不会主动抛错,而是保证返回一个 number。
|
||
*/
|
||
const parseNumber = (value: string | number): number => {
|
||
if (typeof value === 'number') return value
|
||
const num = parseFloat(String(value))
|
||
return isNaN(num) ? 0 : num
|
||
}
|
||
|
||
// 最大位置,避免超出了范围导致展示异常
|
||
const maxPosition = computed(() => {
|
||
const width = parseNumber(props.width)
|
||
const height = parseNumber(props.height)
|
||
if (!isFinite(width) || !isFinite(height) || width <= 0 || height <= 0) {
|
||
return 0
|
||
}
|
||
return Math.max(0, width - height)
|
||
})
|
||
|
||
// 完成状态判断
|
||
const isComplete = computed(() => {
|
||
const distance = Math.abs(maxPosition.value - currentPosition.value)
|
||
return distance <= parseNumber(props.tolerance) // 容差范围内完成
|
||
})
|
||
|
||
// 位置状态
|
||
const currentPosition = ref<number>(0)
|
||
const startPosition = ref<number>(0)
|
||
// 成功状态
|
||
const isPass = ref<boolean>(false)
|
||
// 拖动状态
|
||
const isDragging = ref<boolean>(false)
|
||
// 回弹
|
||
const isResetting = ref(false)
|
||
|
||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max))
|
||
const updatePosition = (position: number) => {
|
||
// 限制位置在允许范围内
|
||
currentPosition.value = clamp(position, 0, maxPosition.value)
|
||
}
|
||
|
||
const isDisabled = computed(() => props.disabled || isPass.value)
|
||
const onTouchStart = (event: TouchEvent): void => {
|
||
if (isDisabled.value || isDragging.value) return
|
||
|
||
touch.touchStart(event)
|
||
startPosition.value = currentPosition.value
|
||
isDragging.value = true
|
||
}
|
||
|
||
const onTouchMove = (event: TouchEvent): void => {
|
||
if (isDisabled.value || !isDragging.value) return
|
||
|
||
touch.touchMove(event)
|
||
updatePosition(startPosition.value + touch.deltaX.value)
|
||
}
|
||
// 控制回弹
|
||
const timer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||
|
||
const onTouchEnd = (): void => {
|
||
if (isDisabled.value || !isDragging.value) return
|
||
isDragging.value = false
|
||
|
||
if (isComplete.value) {
|
||
// 完成
|
||
updatePosition(maxPosition.value)
|
||
isPass.value = true
|
||
emit('success')
|
||
} else {
|
||
isResetting.value = true
|
||
// 失败回到起点
|
||
updatePosition(0)
|
||
emit('fail')
|
||
|
||
timer.value = setTimeout(() => {
|
||
isResetting.value = false
|
||
}, 300)
|
||
}
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
if (timer.value !== null) {
|
||
clearTimeout(timer.value)
|
||
timer.value = null
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 重置验证组件到初始状态
|
||
*/
|
||
const reset = () => {
|
||
if (timer.value !== null) {
|
||
clearTimeout(timer.value)
|
||
timer.value = null
|
||
}
|
||
isResetting.value = true
|
||
currentPosition.value = 0
|
||
startPosition.value = 0
|
||
isPass.value = false
|
||
isDragging.value = false
|
||
timer.value = setTimeout(() => {
|
||
isResetting.value = false
|
||
}, 300)
|
||
}
|
||
|
||
defineExpose<SlideVerifyExpose>({ reset })
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import './index.scss';
|
||
</style>
|