验证码组件封装
This commit is contained in:
parent
78bed71168
commit
e581ced7e8
1
components.d.ts
vendored
1
components.d.ts
vendored
@ -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']
|
||||
|
||||
126
src/components/CaptchaPanel.vue
Normal file
126
src/components/CaptchaPanel.vue
Normal 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>
|
||||
@ -3,7 +3,6 @@
|
||||
background-size: 100% 180px;
|
||||
position: absolute;
|
||||
transform: translate(0px, 0px);
|
||||
/* border-bottom: 1px solid blue; */
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user