验证码组件封装

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' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
CaptchaPanel: typeof import('./src/components/CaptchaPanel.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside'] ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] 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; background-size: 100% 180px;
position: absolute; position: absolute;
transform: translate(0px, 0px); transform: translate(0px, 0px);
/* border-bottom: 1px solid blue; */
z-index: 1; z-index: 1;
width: 100%; width: 100%;
} }

View File

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

View File

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