252 lines
8.5 KiB
Vue
252 lines
8.5 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, computed } from 'vue'
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { RefreshCw, Loader2 } from 'lucide-vue-next'
|
|
import { authApi } from '@/api/auth'
|
|
import { message as toast } from '@/composables/useMessage'
|
|
|
|
const open = defineModel<boolean>('open', { default: false })
|
|
|
|
const emit = defineEmits<{
|
|
confirm: [result: { captchaKey: string; dots: Array<{ x: number; y: number }> }]
|
|
}>()
|
|
|
|
// 验证码数据
|
|
const bgImage = ref('')
|
|
const thumbImage = ref('')
|
|
const thumbX = ref(0)
|
|
const thumbY = ref(0)
|
|
const thumbWidth = ref(0)
|
|
const thumbHeight = ref(0)
|
|
const captchaKey = ref('')
|
|
const loading = ref(false)
|
|
|
|
// 滑块拖动状态
|
|
const isDragging = ref(false)
|
|
const dragStartX = ref(0)
|
|
const sliderOffset = ref(0)
|
|
const submitting = ref(false)
|
|
|
|
// 背景图实际渲染尺寸(固定宽度,按比例缩放)
|
|
const bgNaturalWidth = ref(0)
|
|
const bgNaturalHeight = ref(0)
|
|
const DISPLAY_WIDTH = 320
|
|
|
|
const scale = computed(() => bgNaturalWidth.value ? DISPLAY_WIDTH / bgNaturalWidth.value : 1)
|
|
const displayHeight = computed(() => Math.round(bgNaturalHeight.value * scale.value))
|
|
|
|
// 滑块在图片上的显示位置
|
|
const thumbDisplayX = computed(() => Math.round(thumbX.value * scale.value) + sliderOffset.value)
|
|
const thumbDisplayY = computed(() => Math.round(thumbY.value * scale.value))
|
|
const thumbDisplayW = computed(() => Math.round(thumbWidth.value * scale.value))
|
|
const thumbDisplayH = computed(() => Math.round(thumbHeight.value * scale.value))
|
|
|
|
function resetState() {
|
|
bgImage.value = ''
|
|
thumbImage.value = ''
|
|
sliderOffset.value = 0
|
|
submitting.value = false
|
|
isDragging.value = false
|
|
bgNaturalWidth.value = 0
|
|
bgNaturalHeight.value = 0
|
|
}
|
|
|
|
async function loadCaptcha() {
|
|
loading.value = true
|
|
sliderOffset.value = 0
|
|
submitting.value = false
|
|
try {
|
|
const res = await authApi.getCaptcha()
|
|
const d = res.data
|
|
bgImage.value = d.image || ''
|
|
thumbImage.value = d.thumb || ''
|
|
thumbX.value = d.thumbX || 0
|
|
thumbY.value = d.thumbY || 0
|
|
thumbWidth.value = d.thumbWidth || 0
|
|
thumbHeight.value = d.thumbHeight || 0
|
|
captchaKey.value = d.captchaKey || ''
|
|
} catch {
|
|
toast.error('获取验证码失败')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function onBgLoad(e: Event) {
|
|
const img = e.target as HTMLImageElement
|
|
bgNaturalWidth.value = img.naturalWidth
|
|
bgNaturalHeight.value = img.naturalHeight
|
|
}
|
|
|
|
watch(open, (val) => {
|
|
if (val) {
|
|
resetState()
|
|
loadCaptcha()
|
|
}
|
|
})
|
|
|
|
// 滑块拖动逻辑
|
|
function onDragStart(e: MouseEvent | TouchEvent) {
|
|
if (submitting.value) return
|
|
isDragging.value = true
|
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
|
dragStartX.value = clientX - sliderOffset.value
|
|
document.addEventListener('mousemove', onDragMove)
|
|
document.addEventListener('mouseup', onDragEnd)
|
|
document.addEventListener('touchmove', onDragMove, { passive: false })
|
|
document.addEventListener('touchend', onDragEnd)
|
|
}
|
|
|
|
function onDragMove(e: MouseEvent | TouchEvent) {
|
|
if (!isDragging.value) return
|
|
if ('touches' in e) e.preventDefault()
|
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
|
const maxOffset = DISPLAY_WIDTH - Math.round(thumbX.value * scale.value) - thumbDisplayW.value
|
|
const minOffset = -Math.round(thumbX.value * scale.value)
|
|
let offset = clientX - dragStartX.value
|
|
offset = Math.max(minOffset, Math.min(maxOffset, offset))
|
|
sliderOffset.value = offset
|
|
}
|
|
|
|
function onDragEnd() {
|
|
if (!isDragging.value) return
|
|
isDragging.value = false
|
|
document.removeEventListener('mousemove', onDragMove)
|
|
document.removeEventListener('mouseup', onDragEnd)
|
|
document.removeEventListener('touchmove', onDragMove)
|
|
document.removeEventListener('touchend', onDragEnd)
|
|
handleConfirm()
|
|
}
|
|
|
|
function handleConfirm() {
|
|
submitting.value = true
|
|
const actualX = Math.round(thumbX.value + sliderOffset.value / scale.value)
|
|
const actualY = thumbY.value
|
|
emit('confirm', {
|
|
captchaKey: captchaKey.value,
|
|
dots: [{ x: actualX, y: actualY }],
|
|
})
|
|
// 不在这里关闭弹窗,由父组件在请求完成后关闭
|
|
}
|
|
|
|
// 滑动条轨道上的滑块位置
|
|
const sliderTrackPercent = computed(() => {
|
|
const maxTrack = DISPLAY_WIDTH - 40
|
|
const maxOffset = DISPLAY_WIDTH - Math.round(thumbX.value * scale.value) - thumbDisplayW.value
|
|
const minOffset = -Math.round(thumbX.value * scale.value)
|
|
const range = maxOffset - minOffset
|
|
if (range <= 0) return 0
|
|
return ((sliderOffset.value - minOffset) / range) * maxTrack
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog v-model:open="open">
|
|
<DialogContent class="sm:max-w-[380px] p-0 overflow-hidden" @pointerDownOutside.prevent @escapeKeyDown.prevent="!submitting && (open = false)">
|
|
<DialogHeader class="px-4 pt-4 pb-2">
|
|
<DialogTitle>安全验证</DialogTitle>
|
|
<DialogDescription>拖动滑块将拼图移到正确位置</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div class="px-4 pb-4 space-y-3">
|
|
<!-- 验证码图片区域 -->
|
|
<div
|
|
class="relative select-none overflow-hidden rounded-md border bg-muted mx-auto"
|
|
:style="{ width: DISPLAY_WIDTH + 'px', height: displayHeight ? displayHeight + 'px' : '200px' }"
|
|
>
|
|
<!-- loading 遮罩 -->
|
|
<div
|
|
v-if="loading || submitting"
|
|
class="absolute inset-0 z-10 flex items-center justify-center bg-background/60"
|
|
>
|
|
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
|
<span class="ml-2 text-sm text-muted-foreground">
|
|
{{ submitting ? '验证中...' : '加载中...' }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- 背景图 -->
|
|
<img
|
|
v-if="bgImage"
|
|
:src="bgImage"
|
|
alt="验证码背景"
|
|
class="block w-full h-full object-fill"
|
|
draggable="false"
|
|
@load="onBgLoad"
|
|
/>
|
|
<div
|
|
v-else-if="!loading"
|
|
class="w-full h-full flex items-center justify-center text-muted-foreground text-sm"
|
|
>
|
|
验证码加载失败
|
|
</div>
|
|
|
|
<!-- 滑块拼图 -->
|
|
<img
|
|
v-if="thumbImage && !loading"
|
|
:src="thumbImage"
|
|
alt="滑块"
|
|
class="absolute pointer-events-none drop-shadow-lg"
|
|
:class="{ 'transition-none': isDragging }"
|
|
:style="{
|
|
left: thumbDisplayX + 'px',
|
|
top: thumbDisplayY + 'px',
|
|
width: thumbDisplayW + 'px',
|
|
height: thumbDisplayH + 'px',
|
|
}"
|
|
draggable="false"
|
|
/>
|
|
</div>
|
|
|
|
<!-- 滑动条 -->
|
|
<div
|
|
class="relative h-10 rounded-full border bg-muted/50 select-none mx-auto"
|
|
:style="{ width: DISPLAY_WIDTH + 'px' }"
|
|
>
|
|
<!-- 已滑过的区域 -->
|
|
<div
|
|
class="absolute inset-y-0 left-0 rounded-full bg-primary/20"
|
|
:style="{ width: (sliderTrackPercent + 20) + 'px' }"
|
|
/>
|
|
|
|
<!-- 提示文字 -->
|
|
<span
|
|
v-if="sliderOffset === 0 && !submitting"
|
|
class="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground pointer-events-none"
|
|
>
|
|
向右拖动滑块完成验证
|
|
</span>
|
|
|
|
<!-- 滑块按钮 -->
|
|
<div
|
|
class="absolute top-0 h-10 w-10 rounded-full border-2 bg-background shadow-md flex items-center justify-center cursor-grab active:cursor-grabbing transition-colors"
|
|
:class="[
|
|
isDragging ? 'border-primary ring-2 ring-primary/30' : 'border-muted-foreground/30',
|
|
submitting ? 'opacity-50 cursor-not-allowed' : '',
|
|
]"
|
|
:style="{ left: sliderTrackPercent + 'px' }"
|
|
@mousedown.prevent="onDragStart"
|
|
@touchstart.prevent="onDragStart"
|
|
>
|
|
<Loader2 v-if="submitting" class="h-4 w-4 animate-spin text-primary" />
|
|
<svg v-else width="14" height="14" viewBox="0 0 14 14" fill="none" class="text-muted-foreground">
|
|
<path d="M9 3l4 4-4 4M5 3L1 7l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 刷新按钮 -->
|
|
<div class="flex justify-end">
|
|
<Button variant="ghost" size="sm" @click="loadCaptcha" :disabled="loading || submitting">
|
|
<RefreshCw class="h-4 w-4 mr-1" />
|
|
刷新
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|