登录验证码接入

This commit is contained in:
biss committed 2026-09-11 22:05:07 +08:00
1 parent ae075bda6d
commit f489a3553c
15 files changed
+471 -19

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import http from '../api/http'
import { getLoginDeviceId } from '../auth/deviceFingerprint'
type CaptchaKind = 'Text' | 'Slider' | 'Click'
interface Point { x: number; y: number }
interface Challenge {
id: string
kind: CaptchaKind
expiresInSeconds: number
imageSvg: string
prompt: string
}
const emit = defineEmits<{ verified: [ticket: string, deviceId: string]; invalidated: [] }>()
const challenge = ref<Challenge | null>(null)
const loading = ref(false)
const verifying = ref(false)
const text = ref('')
const sliderX = ref(0)
const clicks = reactive<Point[]>([])
const image = ref<HTMLElement | null>(null)
const isVerified = ref(false)
const canVerify = computed(() =>
challenge.value?.kind === 'Text' ? text.value.trim().length > 0
: challenge.value?.kind === 'Slider' ? sliderX.value > 0
: clicks.length === 2)
async function refresh() {
loading.value = true
isVerified.value = false
text.value = ''
sliderX.value = 0
clicks.splice(0)
emit('invalidated')
try {
const deviceId = await getLoginDeviceId()
const { data } = await http.post('/auth/login/captcha', { deviceId }, { skipAuthSession: true })
challenge.value = data
} finally {
loading.value = false
}
}
function onClick(event: MouseEvent) {
if (challenge.value?.kind !== 'Click' || isVerified.value || clicks.length >= 2 || !image.value) return
const bounds = image.value.getBoundingClientRect()
clicks.push({
x: Math.round((event.clientX - bounds.left) * 240 / bounds.width),
y: Math.round((event.clientY - bounds.top) * 132 / bounds.height),
})
}
async function verify() {
if (!challenge.value || !canVerify.value) return
verifying.value = true
try {
const deviceId = await getLoginDeviceId()
const { data } = await http.post('/auth/login/captcha/verify', {
challengeId: challenge.value.id,
deviceId,
text: text.value,
sliderX: sliderX.value,
clicks,
}, { skipAuthSession: true })
isVerified.value = true
emit('verified', String(data.captchaTicket), deviceId)
ElMessage.success('安全验证通过')
} catch {
ElMessage.error('验证未通过,请按提示重试。')
await refresh()
} finally {
verifying.value = false
}
}
onMounted(refresh)
defineExpose({ refresh })
</script>
<template>
<section class="login-captcha" aria-label="安全验证">
<div class="captcha-heading"><span>安全验证</span><button type="button" :disabled="loading" @click="refresh">换一题</button></div>
<p v-if="challenge" class="captcha-prompt">{{ challenge.prompt }}</p>
<div v-loading="loading" class="captcha-stage">
<template v-if="challenge">
<div ref="image" class="captcha-image" :class="{ clickable: challenge.kind === 'Click' }" @click="onClick">
<img :src="challenge.imageSvg" alt="验证码图像" draggable="false" />
<i v-for="(point, index) in clicks" :key="`${point.x}-${point.y}`" :style="{ left: `${point.x / 2.4}%`, top: `${point.y / 1.32}%` }">{{ index + 1 }}</i>
</div>
<el-input v-if="challenge.kind === 'Text'" v-model="text" maxlength="5" autocomplete="off" placeholder="输入图中字符" />
<div v-else-if="challenge.kind === 'Slider'" class="slider-control">
<span class="slider-piece" :style="{ left: `${sliderX}px` }">◆</span>
<input v-model.number="sliderX" type="range" min="0" max="220" aria-label="拼图滑块" />
</div>
<small v-else-if="challenge.kind === 'Click'">已选择 {{ clicks.length }}/2 个字符</small>
</template>
</div>
<el-button type="default" :disabled="!canVerify || isVerified" :loading="verifying" @click="verify">
{{ isVerified ? '已验证' : '完成验证' }}
</el-button>
</section>
</template>
<style scoped>
.login-captcha { display: grid; gap: 8px; }
.captcha-heading { display: flex; justify-content: space-between; align-items: center; font-size: 14px; font-weight: 650; color: #233b54; }
.captcha-heading button { border: 0; background: transparent; color: #467cae; cursor: pointer; font: inherit; }
.captcha-prompt { margin: 0; color: #5c7084; font-size: 13px; }
.captcha-stage { min-height: 132px; display: grid; gap: 8px; }
.captcha-image { position: relative; width: min(100%, 280px); line-height: 0; overflow: hidden; border-radius: 9px; }
.captcha-image.clickable { cursor: crosshair; }
.captcha-image img { width: 100%; user-select: none; }
.captcha-image i { position: absolute; transform: translate(-50%, -50%); display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: #1769aa; color: white; font-size: 12px; font-style: normal; line-height: 1; }
.slider-control { position: relative; width: min(100%, 280px); height: 35px; display: grid; place-items: center; background: #edf3f8; border-radius: 7px; }
.slider-control input { width: calc(100% - 18px); accent-color: #347ab1; }
.slider-piece { position: absolute; top: -29px; color: white; text-shadow: 0 1px 3px #1d3a55; pointer-events: none; }
small { color: #63768b; }
</style>