验证码组件封装

This commit is contained in:
灌糖包子 2026-05-16 13:21:55 +08:00
parent 78bed71168
commit e581ced7e8
Signed by: sookie
GPG Key ID: 0599BECB75C1E68D
5 changed files with 200 additions and 119 deletions

1
components.d.ts vendored
View File

@ -7,6 +7,7 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
CaptchaPanel: typeof import('./src/components/CaptchaPanel.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']

View File

@ -0,0 +1,126 @@
<template>
<div ref="captchaBoxRef" class="captcha-panel"></div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import {
createTianAiCaptcha,
type TianAiCaptchaConfig,
type TianAiCaptchaInstance,
type TianAiCaptchaResult,
type TianAiCaptchaStyle
} from '@/vendor/tianai-captcha'
type CaptchaMode = 'data' | 'validate'
type CaptchaType = 'RANDOM' | 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
const props = withDefaults(defineProps<{
mode?: CaptchaMode
type?: CaptchaType
validCaptchaUrl?: string
requestHeaders?: Record<string, string>
styleConfig?: TianAiCaptchaStyle
}>(), {
mode: 'data',
type: 'RANDOM'
})
const emit = defineEmits<{
(e: 'close'): void
(e: 'data-ready', result: TianAiCaptchaResult): void
(e: 'init-error', error: Error): void
(e: 'state-change', active: boolean): void
(e: 'valid-fail', res: any): void
(e: 'valid-success', res: any): void
}>()
const captchaBoxRef = ref<HTMLElement | null>(null)
const captchaInstance = ref<TianAiCaptchaInstance | null>(null)
const active = ref(false)
const requestCaptchaBaseUrl = buildCaptchaApiUrl('/captcha/gen')
const resolvedValidCaptchaUrl = computed(() => {
return props.validCaptchaUrl || buildCaptchaApiUrl('/captcha/check')
})
const requestCaptchaDataUrl = computed(() => {
if (props.type === 'RANDOM') {
return requestCaptchaBaseUrl
}
return `${requestCaptchaBaseUrl}?type=${encodeURIComponent(props.type)}`
})
async function init() {
await nextTick()
if (!captchaBoxRef.value) {
emit('init-error', new Error('验证码容器初始化失败'))
return
}
destroy()
const config: TianAiCaptchaConfig = {
bindEl: captchaBoxRef.value,
requestCaptchaDataUrl: requestCaptchaDataUrl.value,
requestHeaders: props.requestHeaders,
btnRefreshFun: (_el, tac) => {
tac.reloadCaptcha()
},
btnCloseFun: () => {
destroy()
emit('close')
}
}
switch (props.mode) {
case 'validate':
config.validCaptchaUrl = resolvedValidCaptchaUrl.value
config.validSuccess = res => emit('valid-success', res)
config.validFail = res => emit('valid-fail', res)
break
case 'data':
config.onDataReady = result => emit('data-ready', result)
break
default:
emit('init-error', new Error(`未知的验证码模式: ${props.mode}`))
return
}
captchaInstance.value = createTianAiCaptcha(config, props.styleConfig)
captchaInstance.value.init()
setActive(true)
}
function reload() {
captchaInstance.value?.reloadCaptcha()
}
function destroy() {
if (captchaInstance.value) {
captchaInstance.value.destroyWindow()
captchaInstance.value = null
}
setActive(false)
}
function setActive(nextValue: boolean) {
if (active.value === nextValue) return
active.value = nextValue
emit('state-change', nextValue)
}
defineExpose({
destroy,
init,
reload,
})
function buildCaptchaApiUrl(path: string) {
const normalizedBase = (import.meta.env.VITE_APP_API_BASE || '').replace(/\/+$/, '')
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${normalizedBase}${normalizedPath}`
}
onBeforeUnmount(() => destroy())
</script>

View File

@ -3,7 +3,6 @@
background-size: 100% 180px;
position: absolute;
transform: translate(0px, 0px);
/* border-bottom: 1px solid blue; */
z-index: 1;
width: 100%;
}

View File

@ -53,7 +53,13 @@
@closed="handleCaptchaDialogClosed"
>
<p class="captcha-dialog-subtitle">请先完成验证码校验再继续登录</p>
<div ref="captchaBoxRef" class="captcha-box"></div>
<CaptchaPanel
ref="captchaRef"
class="captcha-box"
@close="closeCaptchaDialog"
@data-ready="handleCaptchaSolved"
@init-error="handleCaptchaInitError"
/>
</el-dialog>
</div>
</template>
@ -63,13 +69,9 @@ import { useStore } from 'vuex'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { VForm } from '../types'
import CaptchaPanel from '@/components/CaptchaPanel.vue'
import http from '@/utils/http'
import {
createTianAiCaptcha,
type TianAiCaptchaConfig,
type TianAiCaptchaInstance,
type TianAiCaptchaResult
} from '@/vendor/tianai-captcha'
import type { TianAiCaptchaResult } from '@/vendor/tianai-captcha'
type LoginFormState = {
username: string | null,
@ -87,13 +89,17 @@ type LoginConfigResponse = {
}
}
type CaptchaPanelRef = {
destroy: () => void
init: () => Promise<void>
reload: () => void
}
const store = useStore()
const router = useRouter()
const loginForm = ref<VForm>()
const captchaBoxRef = ref<HTMLElement | null>(null)
const captchaRef = ref<CaptchaPanelRef | null>(null)
const captchaDialogVisible = ref(false)
const captchaInstance = ref<TianAiCaptchaInstance | null>(null)
const requestCaptchaDataUrl = buildApiUrl('/captcha/gen')
const captchaErrorPattern = /验证码|captcha/i
const userInfo: LoginFormState = reactive({
@ -119,7 +125,7 @@ localStorage.clear()
async function openCaptchaDialog() {
captchaDialogVisible.value = true
await nextTick()
initCaptcha()
await captchaRef.value?.init()
}
async function handleLogin() {
@ -148,31 +154,6 @@ async function handleLogin() {
})
}
function initCaptcha() {
if (!captchaBoxRef.value) {
captchaDialogVisible.value = false
ElMessage.error('验证码容器初始化失败')
return
}
destroyCaptcha(false)
const config: TianAiCaptchaConfig = {
bindEl: captchaBoxRef.value,
requestCaptchaDataUrl,
onDataReady: handleCaptchaSolved,
btnRefreshFun: (_el, tac) => {
tac.reloadCaptcha()
},
btnCloseFun: () => {
closeCaptchaDialog()
}
}
captchaInstance.value = createTianAiCaptcha(config)
captchaInstance.value.init()
}
async function handleCaptchaSolved(result: TianAiCaptchaResult) {
if (loading.value) return
@ -186,7 +167,7 @@ async function handleCaptchaSolved(result: TianAiCaptchaResult) {
})
} catch (error) {
if (isCaptchaError(error)) {
captchaInstance.value?.reloadCaptcha()
captchaRef.value?.reload()
} else {
closeCaptchaDialog()
}
@ -207,29 +188,19 @@ async function submitLogin(payload: LoginRequest) {
function closeCaptchaDialog(resetLoading = true) {
captchaDialogVisible.value = false
destroyCaptcha(false)
captchaRef.value?.destroy()
if (resetLoading) {
loading.value = false
}
}
function handleCaptchaDialogClosed() {
destroyCaptcha(false)
captchaRef.value?.destroy()
}
function destroyCaptcha(showMessage = false) {
if (!captchaInstance.value) return
captchaInstance.value.destroyWindow()
captchaInstance.value = null
if (showMessage) {
console.info('[login captcha] destroy captcha instance')
}
}
function buildApiUrl(path: string) {
const normalizedBase = (import.meta.env.VITE_APP_API_BASE || '').replace(/\/+$/, '')
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${normalizedBase}${normalizedPath}`
function handleCaptchaInitError() {
captchaDialogVisible.value = false
ElMessage.error('验证码初始化失败')
}
function isCaptchaError(error: unknown) {
@ -239,7 +210,7 @@ function isCaptchaError(error: unknown) {
return false
}
onBeforeUnmount(() => destroyCaptcha())
onBeforeUnmount(() => captchaRef.value?.destroy())
</script>
<style lang="less" scoped>
.login-page {

View File

@ -2,8 +2,8 @@
<div>
<div class="hero-actions">
<el-button type="primary" @click="startCaptcha" plain>开始验证</el-button>
<el-button @click="reloadCaptcha" :disabled="!captchaInstance">刷新</el-button>
<el-button @click="destroyCaptcha" :disabled="!captchaInstance" plain>销毁</el-button>
<el-button @click="reloadCaptcha" :disabled="!captchaActive">刷新</el-button>
<el-button @click="destroyCaptcha" :disabled="!captchaActive" plain>销毁</el-button>
</div>
<div class="sandbox-grid">
@ -19,24 +19,33 @@
<el-card class="preview-card" shadow="never">
<template #header>验证码预览</template>
<div ref="captchaBoxRef" class="captcha-box"></div>
<CaptchaPanel
ref="captchaRef"
class="captcha-box"
mode="validate"
:type="selectedType"
@close="handleCaptchaClose"
@state-change="handleCaptchaStateChange"
@valid-fail="handleValidFail"
@valid-success="handleValidSuccess"
/>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import {
createTianAiCaptcha,
type TianAiCaptchaConfig,
type TianAiCaptchaInstance
} from '@/vendor/tianai-captcha'
import { onBeforeUnmount, ref } from 'vue'
import CaptchaPanel from '@/components/CaptchaPanel.vue'
type CaptchaType = 'RANDOM' | 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK'
const requestCaptchaBaseUrl = buildApiUrl('/captcha/gen')
const validCaptchaUrl = buildApiUrl('/captcha/check')
type CaptchaPanelRef = {
destroy: () => void
init: () => Promise<void>
reload: () => void
}
const captchaTypeOptions: Array<{ label: string, value: CaptchaType }> = [
{ label: '随机', value: 'RANDOM' },
{ label: '滑块拼图', value: 'SLIDER' },
@ -45,91 +54,66 @@ const captchaTypeOptions: Array<{ label: string, value: CaptchaType }> = [
{ label: '汉字点选', value: 'WORD_IMAGE_CLICK' },
]
const captchaInstance = ref<TianAiCaptchaInstance | null>(null)
const captchaBoxRef = ref<HTMLElement | null>(null)
const captchaRef = ref<CaptchaPanelRef | null>(null)
const captchaActive = ref(false)
const selectedType = ref<CaptchaType>('RANDOM')
const requestCaptchaDataUrl = computed(() => {
if (selectedType.value === 'RANDOM') {
return requestCaptchaBaseUrl
}
return `${requestCaptchaBaseUrl}?type=${encodeURIComponent(selectedType.value)}`
})
function startCaptcha() {
if (!captchaBoxRef.value) {
console.warn('[captcha sandbox] captcha container is not ready')
return
}
destroyCaptcha(false)
const config: TianAiCaptchaConfig = {
bindEl: captchaBoxRef.value,
requestCaptchaDataUrl: requestCaptchaDataUrl.value,
validCaptchaUrl,
validSuccess: (res, _captcha, tac) => {
console.log('[captcha sandbox] valid success', res)
tac.destroyWindow()
},
validFail: (res, _captcha, tac) => {
console.warn('[captcha sandbox] valid fail', res)
tac.reloadCaptcha()
},
btnRefreshFun: (_el, tac) => {
console.info('[captcha sandbox] refresh captcha')
tac.reloadCaptcha()
},
btnCloseFun: (_el, tac) => {
console.info('[captcha sandbox] close captcha window')
tac.destroyWindow()
}
}
async function startCaptcha() {
console.info('[captcha sandbox] init captcha', {
type: selectedType.value,
requestCaptchaDataUrl: requestCaptchaDataUrl.value,
validCaptchaUrl
mode: 'validate'
})
captchaInstance.value = createTianAiCaptcha(config)
captchaInstance.value.init()
await captchaRef.value?.init()
}
function reloadCaptcha() {
if (!captchaInstance.value) return
if (!captchaActive.value) return
console.info('[captcha sandbox] manual reload')
captchaInstance.value.reloadCaptcha()
captchaRef.value?.reload()
}
function destroyCaptcha(showMessage = true) {
if (!captchaInstance.value) return
captchaInstance.value.destroyWindow()
captchaInstance.value = null
if (!captchaActive.value) return
captchaRef.value?.destroy()
if (showMessage) {
console.info('[captcha sandbox] destroy captcha instance')
}
}
function buildApiUrl(path: string) {
const normalizedBase = (import.meta.env.VITE_APP_API_BASE || '').replace(/\/+$/, '')
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${normalizedBase}${normalizedPath}`
function handleCaptchaClose() {
console.info('[captcha sandbox] close captcha window')
}
onBeforeUnmount(() => destroyCaptcha(false))
function handleCaptchaStateChange(active: boolean) {
captchaActive.value = active
}
function handleValidSuccess(res: any) {
console.log('[captcha sandbox] valid success', res)
captchaRef.value?.destroy()
}
function handleValidFail(res: any) {
console.warn('[captcha sandbox] valid fail', res)
captchaRef.value?.reload()
}
onBeforeUnmount(() => captchaRef.value?.destroy())
</script>
<style lang="less" scoped>
.hero-actions {
margin-bottom: 24px;
}
.sandbox-grid {
display: flex;
gap: 20px;
.el-card {
flex: 1;
&.preview-card {
flex: 0 0 420px;
}