Files
workspace/code/fms/fms-vue/src/components/captcha/SliderCaptcha.vue
T
2026-09-01 17:32:43 +08:00

306 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>