Files
likedream-web/src/components/login/CaptchaDialog.vue
T
2026-03-24 17:45:16 +08:00

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>