验证码优化
This commit is contained in:
1 parent
f489a3553c
commit
4a3bb84aa4
17 files changed
+156
-430
No files matched your search
@@ -1,106 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { onMounted, ref, watch } 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
|
||||
interface CaptchaImage {
|
||||
captchaId: string
|
||||
imageData: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ verified: [ticket: string, deviceId: string]; invalidated: [] }>()
|
||||
const challenge = ref<Challenge | null>(null)
|
||||
const emit = defineEmits<{ change: [captchaId: string, captchaCode: string] }>()
|
||||
const captcha = ref<CaptchaImage | null>(null)
|
||||
const code = ref('')
|
||||
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')
|
||||
code.value = ''
|
||||
try {
|
||||
const deviceId = await getLoginDeviceId()
|
||||
const { data } = await http.post('/auth/login/captcha', { deviceId }, { skipAuthSession: true })
|
||||
challenge.value = data
|
||||
const { data } = await http.post('/auth/login/captcha', {}, { skipAuthSession: true })
|
||||
captcha.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
|
||||
}
|
||||
}
|
||||
|
||||
watch([captcha, code], () => emit('change', captcha.value?.captchaId ?? '', code.value))
|
||||
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>
|
||||
<section class="login-captcha" aria-label="验证码">
|
||||
<div class="captcha-heading">
|
||||
<span>验证码</span>
|
||||
<button type="button" :disabled="loading" @click="refresh">换一张</button>
|
||||
</div>
|
||||
<div v-loading="loading" class="captcha-input">
|
||||
<img v-if="captcha" :src="captcha.imageData" alt="验证码图片,点击可更换" @click="refresh" />
|
||||
<el-input v-model="code" maxlength="8" autocomplete="off" placeholder="输入图中字符" />
|
||||
</div>
|
||||
<el-button type="default" :disabled="!canVerify || isVerified" :loading="verifying" @click="verify">
|
||||
{{ isVerified ? '已验证' : '完成验证' }}
|
||||
</el-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -108,14 +45,7 @@ defineExpose({ refresh })
|
||||
.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; }
|
||||
.captcha-input { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 10px; align-items: center; }
|
||||
.captcha-input img { width: 150px; height: 50px; object-fit: contain; border: 1px solid #d7e2ed; border-radius: 6px; cursor: pointer; background: #f7fafc; }
|
||||
@media (max-width: 420px) { .captcha-input { grid-template-columns: 130px minmax(0, 1fr); } .captcha-input img { width: 130px; } }
|
||||
</style>
|
||||
Reference in new issue
Block a user