306 lines
7.7 KiB
Vue
306 lines
7.7 KiB
Vue
<script setup>
|
||
/**
|
||
* 滑块验证码(参考 vben-admin SliderCaptcha),拖拽至最右端完成验证。
|
||
* 通过 v-model 双向绑定布尔值,值为 true 时表示验证通过。
|
||
*/
|
||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { Check, ChevronsRight } from '@lucide/vue'
|
||
|
||
const props = defineProps({
|
||
modelValue: { type: Boolean, default: false },
|
||
text: { type: String, default: '请按住滑块拖动' },
|
||
successText: { type: String, default: '验证通过' },
|
||
disabled: { type: Boolean, default: false },
|
||
})
|
||
|
||
const emit = defineEmits(['update:modelValue', 'success', 'start', 'move', 'end'])
|
||
|
||
/* ================= 状态 ================= */
|
||
const wrapperRef = ref(null)
|
||
const actionRef = ref(null)
|
||
const barRef = ref(null)
|
||
|
||
const isMoving = ref(false)
|
||
const isPassing = ref(false)
|
||
const moveStartX = ref(0)
|
||
const moveDistance = ref(0)
|
||
const toLeft = ref(false)
|
||
const actionLeft = ref('0')
|
||
const barWidth = ref('0')
|
||
|
||
// 同步外部 v-model 到内部状态
|
||
watch(
|
||
() => props.modelValue,
|
||
(val) => {
|
||
if (val !== isPassing.value) {
|
||
isPassing.value = !!val
|
||
}
|
||
},
|
||
)
|
||
|
||
// 通过后 emit 事件(仅用于通知,v-model 已在 checkPass 中直接 emit)
|
||
watch(isPassing, (val) => {
|
||
if (val) {
|
||
emit('success', { isPassing: true })
|
||
}
|
||
})
|
||
|
||
/* ================= 拖拽逻辑 ================= */
|
||
function getEventPageX(e) {
|
||
if ('pageX' in e) return e.pageX
|
||
if ('touches' in e && e.touches[0]) return e.touches[0].pageX
|
||
return 0
|
||
}
|
||
|
||
function getOffset() {
|
||
const wrapper = wrapperRef.value
|
||
const action = actionRef.value
|
||
if (!wrapper || !action) return { actionWidth: 40, offset: 0, wrapperWidth: 220 }
|
||
const wrapperWidth = wrapper.offsetWidth
|
||
const actionWidth = action.offsetWidth
|
||
const offset = wrapperWidth - actionWidth - 6
|
||
return { actionWidth, offset, wrapperWidth }
|
||
}
|
||
|
||
function handleDragStart(e) {
|
||
if (isPassing.value || props.disabled) return
|
||
moveStartX.value = getEventPageX(e)
|
||
const currentLeft = Number.parseInt(actionLeft.value || '0', 10)
|
||
moveDistance.value = moveStartX.value - currentLeft
|
||
isMoving.value = true
|
||
emit('start', e)
|
||
}
|
||
|
||
function handleDragMoving(e) {
|
||
if (!isMoving.value || isPassing.value) return
|
||
const action = actionRef.value
|
||
const bar = barRef.value
|
||
if (!action || !bar) return
|
||
const { actionWidth, offset, wrapperWidth } = getOffset()
|
||
const moveX = getEventPageX(e) - moveDistance.value
|
||
|
||
emit('move', { event: e, moveDistance: moveDistance.value, moveX })
|
||
|
||
if (moveX >= offset - 2) {
|
||
// 拖到右边界(容忍 2px 偏差)
|
||
actionLeft.value = `${wrapperWidth - actionWidth}px`
|
||
barWidth.value = `${wrapperWidth - actionWidth / 2}px`
|
||
checkPass()
|
||
} else if (moveX > 0) {
|
||
actionLeft.value = `${moveX}px`
|
||
barWidth.value = `${moveX + actionWidth / 2}px`
|
||
} else {
|
||
actionLeft.value = '0'
|
||
barWidth.value = '0'
|
||
}
|
||
}
|
||
|
||
// document 级 mouseup 确保鼠标移出 wrapper 也能正确结束拖拽
|
||
function onDocumentMouseUp(e) {
|
||
if (!isMoving.value) return
|
||
handleDragEnd(e)
|
||
}
|
||
|
||
function handleDragEnd(e) {
|
||
if (!isMoving.value || isPassing.value) return
|
||
emit('end', e)
|
||
const action = actionRef.value
|
||
const bar = barRef.value
|
||
if (!action || !bar) return
|
||
const { actionWidth, offset, wrapperWidth } = getOffset()
|
||
const moveX = getEventPageX(e) - moveDistance.value
|
||
|
||
if (moveX >= offset - 2) {
|
||
// 拖到位
|
||
actionLeft.value = `${wrapperWidth - actionWidth}px`
|
||
barWidth.value = `${wrapperWidth - actionWidth / 2}px`
|
||
checkPass()
|
||
} else {
|
||
// 没到位,归位
|
||
resume()
|
||
}
|
||
isMoving.value = false
|
||
}
|
||
|
||
function checkPass() {
|
||
isPassing.value = true
|
||
isMoving.value = false
|
||
emit('update:modelValue', true)
|
||
}
|
||
|
||
function resume() {
|
||
isMoving.value = false
|
||
isPassing.value = false
|
||
moveDistance.value = 0
|
||
emit('update:modelValue', false)
|
||
// 先加过渡类(toLeft),下一帧再改值 → 从当前位置平滑动画回 0
|
||
toLeft.value = true
|
||
nextTick(() => {
|
||
actionLeft.value = '0'
|
||
barWidth.value = '0'
|
||
setTimeout(() => {
|
||
toLeft.value = false
|
||
}, 300)
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('mouseup', onDocumentMouseUp)
|
||
document.addEventListener('touchend', onDocumentMouseUp)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('mouseup', onDocumentMouseUp)
|
||
document.removeEventListener('touchend', onDocumentMouseUp)
|
||
})
|
||
|
||
defineExpose({ resume })
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="wrapperRef"
|
||
class="scaptcha"
|
||
:class="{ 'scaptcha--disabled': disabled, 'scaptcha--passed': isPassing }"
|
||
@mousemove="handleDragMoving"
|
||
@touchmove="handleDragMoving"
|
||
>
|
||
<!-- 进度条 -->
|
||
<div
|
||
ref="barRef"
|
||
class="scaptcha__bar"
|
||
:class="{
|
||
'scaptcha__bar--left': toLeft,
|
||
'scaptcha__bar--passed': isPassing,
|
||
}"
|
||
:style="{ width: barWidth }"
|
||
></div>
|
||
|
||
<!-- 背景文字 -->
|
||
<div
|
||
class="scaptcha__text"
|
||
:class="{ 'scaptcha__text--passed': isPassing }"
|
||
>
|
||
{{ isPassing ? successText : text }}
|
||
</div>
|
||
|
||
<!-- 滑块手柄 -->
|
||
<div
|
||
ref="actionRef"
|
||
class="scaptcha__action"
|
||
:class="{
|
||
'scaptcha__action--left': toLeft,
|
||
'scaptcha__action--disabled': disabled,
|
||
'scaptcha__action--passed': isPassing,
|
||
}"
|
||
:style="{ left: actionLeft }"
|
||
@mousedown="handleDragStart"
|
||
@touchstart="handleDragStart"
|
||
>
|
||
<ChevronsRight v-if="!isPassing" :size="16" />
|
||
<Check v-else :size="16" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.scaptcha {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
height: 40px;
|
||
overflow: hidden;
|
||
border-radius: var(--fms-control-radius);
|
||
border: 1px solid var(--fms-border);
|
||
background: var(--fms-background);
|
||
user-select: none;
|
||
cursor: default;
|
||
}
|
||
|
||
/* ========== 进度条 ========== */
|
||
/* 拖拽过程即显示成功绿(浅色调);默认无过渡,跟手实时 */
|
||
.scaptcha__bar {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
height: 100%;
|
||
background: color-mix(in srgb, var(--fms-success) 78%, #fff);
|
||
border-radius: var(--fms-control-radius) 0 0 var(--fms-control-radius);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 回退归位时才启用宽度过渡,平滑动画回 0 */
|
||
.scaptcha__bar--left {
|
||
transition: width 300ms ease;
|
||
}
|
||
|
||
/* ========== 背景文字(恒黑色,无透明/扫光效果) ========== */
|
||
.scaptcha__text {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
font-size: 13px;
|
||
color: var(--fms-text);
|
||
pointer-events: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.scaptcha__text--passed {
|
||
color: var(--fms-text);
|
||
}
|
||
|
||
/* ========== 滑块手柄 ========== */
|
||
/* 无边框,仅用阴影表现层次(参考 vben shadow-md),拖拽中不出现边框 */
|
||
.scaptcha__action {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 40px;
|
||
height: 100%;
|
||
background: var(--fms-card);
|
||
border-radius: var(--fms-control-radius);
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||
color: var(--fms-text-secondary);
|
||
cursor: grab;
|
||
transition: box-shadow 150ms ease, color 200ms ease;
|
||
z-index: 1;
|
||
}
|
||
|
||
.scaptcha__action:hover {
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.scaptcha__action:active {
|
||
cursor: grabbing;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
/* 回退归位时才启用 left 过渡,平滑动画回 0 */
|
||
.scaptcha__action--left {
|
||
transition: left 300ms ease;
|
||
}
|
||
|
||
.scaptcha__action--disabled {
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.scaptcha__action--passed {
|
||
color: var(--fms-success);
|
||
cursor: default;
|
||
}
|
||
|
||
/* ========== 禁用态整体 ========== */
|
||
/* 禁用仅禁止交互(拖拽由 JS 拦截),不做整体半透明——避免通过后的绿/白被压灰混杂 */
|
||
.scaptcha--disabled {
|
||
cursor: not-allowed;
|
||
}
|
||
</style> |