20260901173242

This commit is contained in:
oneao committed 2026-09-01 17:32:43 +08:00
1 parent 3bbcafc5be
commit 53aba8b956
21 files changed
+1595 -704

No files matched your search

@@ -0,0 +1,306 @@
<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>