登录功能接入验证码

This commit is contained in:
灌糖包子 2026-05-16 12:11:22 +08:00
parent 4c2d41b699
commit e0f6175fed
Signed by: sookie
GPG Key ID: 0599BECB75C1E68D
7 changed files with 203 additions and 50 deletions

View File

@ -15,9 +15,8 @@ const template = `
</div> </div>
<!-- 底部 --> <!-- 底部 -->
<div class="slider-bottom"> <div class="slider-bottom">
<img class="logo" id="tianai-captcha-logo" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAMAAAAM7l6QAAAAMFBMVEVHcEz3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkX3tkVmTmjZAAAAD3RSTlMASbTm8wh12hOGoCNiyTV98jvOAAABB0lEQVR42nVT0aIFEQiMorD0/397Lc5a7J0n1UylgIniLRKyDcbBDudZH2DYCAabn3PmTrjeUX+7rJGWx0SqVpzReAfTtKU5fgVCNfxWjB69USUDGwoOiauHpZEpSr0tCx8ILb3Dm3WgBbAlifAJk6+Ww6wqEUmpmIorQVZ1JtqKnDMjkb7AgIpO/wMCaQbuBuEtsBUxhuD9daUaZnApiQB8NAKotMwirGGr6mbXpPnHLHDmy6oy3FgP+1j8IBdVklFc01xUJwv3NR0rIeXV5zpzdlruiijzNq/ufOeKWzZLP3160u5P8RjT1M+HHFtx+PwGyOZqT/D8ROOfjOInTLBIHjy/hvwHxkwPu5cCE1QAAAAASUVORK5CYII=" id="tianai-captcha-logo"></img> <div class="refresh-btn" id="tianai-captcha-slider-refresh-btn"></div>
<div class="close-btn" id="tianai-captcha-slider-close-btn"></div> <div class="close-btn" id="tianai-captcha-slider-close-btn"></div>
<div class="refresh-btn" id="tianai-captcha-slider-refresh-btn"></div>
</div> </div>
</div> </div>
`; `;
@ -28,7 +27,6 @@ function createCaptchaByType(type, tac) {
case "SLIDER": case "SLIDER":
return new Slider(box, styleConfig); return new Slider(box, styleConfig);
case "ROTATE": case "ROTATE":
case "ROTATE_DEGREE":
return new Rotate(box, styleConfig); return new Rotate(box, styleConfig);
case "CONCAT": case "CONCAT":
return new Concat(box, styleConfig); return new Concat(box, styleConfig);
@ -103,20 +101,12 @@ class TianAiCaptcha {
loadStyle() { loadStyle() {
// 设置样式 // 设置样式
const bgUrl = this.style.bgUrl; const bgUrl = this.style.bgUrl;
const logoUrl = this.style.logoUrl;
if (bgUrl) { if (bgUrl) {
// 背景图片 // 背景图片
this.config.domBindEl this.config.domBindEl
.find("#tianai-captcha-bg-img") .find("#tianai-captcha-bg-img")
.css("background-image", "url(" + bgUrl + ")"); .css("background-image", "url(" + bgUrl + ")");
} }
if (logoUrl && logoUrl !== "") {
// logo
this.config.domBindEl.find("#tianai-captcha-logo").attr("src", logoUrl);
} else if (logoUrl === null) {
// 删除logo
this.config.domBindEl.find("#tianai-captcha-logo").css("display", "none");
}
} }
destroyWindow() { destroyWindow() {
@ -167,6 +157,12 @@ class TianAiCaptcha {
// 清空 // 清空
const id = c.currentCaptchaData.currentCaptchaId; const id = c.currentCaptchaData.currentCaptchaId;
c.currentCaptchaData = undefined; c.currentCaptchaData = undefined;
if (this.config.onDataReady) {
Promise.resolve(this.config.onDataReady({ id, data }, c, this)).catch((error) => {
console.error("[TAC] onDataReady callback failed", error);
});
return;
}
// 调用验证接口 // 调用验证接口
this.config.validCaptcha(id, data, c, this); this.config.validCaptcha(id, data, c, this);
}); });
@ -180,9 +176,7 @@ class TianAiCaptcha {
this.C.el.css("transform", "translateX(300px)"); this.C.el.css("transform", "translateX(300px)");
setTimeout(() => { setTimeout(() => {
this.C.destroy(); this.C.destroy();
if (callback) { callback && callback();
callback();
}
}, 500); }, 500);
} else { } else {
callback(); callback();

View File

@ -1,7 +1,6 @@
#tianai-captcha-parent { #tianai-captcha-parent {
box-shadow: 0 0 11px 0 #999999; box-shadow: 0 0 11px 0 #999999;
width: 318px; width: 318px;
height: 318px;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
z-index: 997; z-index: 997;
@ -47,32 +46,25 @@
} }
.slider-bottom { .slider-bottom {
.close-btn { .close-btn, .refresh-btn {
display: inline-block;
width: 20px; width: 20px;
height: 20px; height: 20px;
background-image: url("../images/icon.png");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: 0 -14px;
float: right;
margin-right: 2px;
cursor: pointer; cursor: pointer;
} }
.close-btn {
background-image: url("../images/icon.png");
background-position: 0 -14px;
margin-right: 2px;
}
.refresh-btn { .refresh-btn {
width: 20px;
height: 20px;
background-image: url("../images/icon.png"); background-image: url("../images/icon.png");
background-position: 0 -167px; background-position: 0 -167px;
background-repeat: no-repeat;
float: right;
margin-right: 10px; margin-right: 10px;
cursor: pointer;
} }
.logo { text-align: right;
height: 30px; margin-top: 8px;
float: left;
}
height: 19px;
width: 100%;
} }
.slider-move-shadow { .slider-move-shadow {
animation: myanimation 2s infinite; animation: myanimation 2s infinite;

View File

@ -70,7 +70,6 @@
top: 0; top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 5px;
.tianai-captcha-slider-bg-div-slice { .tianai-captcha-slider-bg-div-slice {
position: absolute; position: absolute;
} }

View File

@ -8,7 +8,7 @@ class CaptchaConfig {
if (!args.requestCaptchaDataUrl) { if (!args.requestCaptchaDataUrl) {
throw new Error("[TAC] 必须配置 [requestCaptchaDataUrl]请求验证码接口"); throw new Error("[TAC] 必须配置 [requestCaptchaDataUrl]请求验证码接口");
} }
if (!args.validCaptchaUrl) { if (!args.validCaptchaUrl && !args.onDataReady) {
throw new Error("[TAC] 必须配置 [validCaptchaUrl]验证验证码接口"); throw new Error("[TAC] 必须配置 [validCaptchaUrl]验证验证码接口");
} }
this.bindEl = args.bindEl; this.bindEl = args.bindEl;
@ -21,6 +21,9 @@ class CaptchaConfig {
if (args.validFail) { if (args.validFail) {
this.validFail = args.validFail; this.validFail = args.validFail;
} }
if (args.onDataReady) {
this.onDataReady = args.onDataReady;
}
if (args.requestHeaders) { if (args.requestHeaders) {
this.requestHeaders = args.requestHeaders; this.requestHeaders = args.requestHeaders;
} else { } else {

View File

@ -17,7 +17,6 @@
img { img {
height: 100%; height: 100%;
width: 100%; width: 100%;
border-radius: 5px;
} }
} }
.slider-img-div { .slider-img-div {

View File

@ -2,9 +2,13 @@ import { CaptchaConfig, TianAiCaptcha } from "./captcha/captcha.js"
export type TianAiCaptchaBindTarget = string | HTMLElement export type TianAiCaptchaBindTarget = string | HTMLElement
export interface TianAiCaptchaResult {
id: string
data: Record<string, any>
}
export interface TianAiCaptchaStyle { export interface TianAiCaptchaStyle {
btnUrl?: string btnUrl?: string
logoUrl?: string | null
bgUrl?: string bgUrl?: string
moveTrackMaskBgColor?: string moveTrackMaskBgColor?: string
moveTrackMaskBorderColor?: string moveTrackMaskBorderColor?: string
@ -14,11 +18,12 @@ export interface TianAiCaptchaStyle {
export interface TianAiCaptchaConfig { export interface TianAiCaptchaConfig {
bindEl: TianAiCaptchaBindTarget bindEl: TianAiCaptchaBindTarget
requestCaptchaDataUrl: string requestCaptchaDataUrl: string
validCaptchaUrl: string validCaptchaUrl?: string
requestHeaders?: Record<string, string> requestHeaders?: Record<string, string>
timeToTimestamp?: boolean timeToTimestamp?: boolean
validSuccess?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void validSuccess?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void
validFail?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void validFail?: (res: any, captcha: any, tac: TianAiCaptchaInstance) => void
onDataReady?: (result: TianAiCaptchaResult, captcha: any, tac: TianAiCaptchaInstance) => void | Promise<void>
btnRefreshFun?: (el: Event, tac: TianAiCaptchaInstance) => void btnRefreshFun?: (el: Event, tac: TianAiCaptchaInstance) => void
btnCloseFun?: (el: Event, tac: TianAiCaptchaInstance) => void btnCloseFun?: (el: Event, tac: TianAiCaptchaInstance) => void
} }

View File

@ -18,7 +18,7 @@
placeholder="请输入用户名" placeholder="请输入用户名"
prefix-icon="User" prefix-icon="User"
size="large" size="large"
@keyup.enter="login" @keyup.enter="handleLogin"
/> />
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item label="密码" prop="password">
@ -29,35 +29,74 @@
prefix-icon="Lock" prefix-icon="Lock"
size="large" size="large"
show-password show-password
@keyup.enter="login" @keyup.enter="handleLogin"
/> />
</el-form-item> </el-form-item>
<el-button class="login-btn" type="primary" size="large" :loading="loading" @click="login" plain> <el-button class="login-btn" type="primary" size="large" :loading="loading" @click="handleLogin" plain>
</el-button> </el-button>
</el-form> </el-form>
</div> </div>
</div> </div>
<el-dialog
v-model="captchaDialogVisible"
class="captcha-dialog"
width="460px"
align-center
destroy-on-close
append-to-body
title="安全验证"
:close-on-click-modal="!loading"
:close-on-press-escape="!loading"
:show-close="!loading"
@closed="handleCaptchaDialogClosed"
>
<p class="captcha-dialog-subtitle">请先完成验证码校验再继续登录</p>
<div ref="captchaBoxRef" class="captcha-box"></div>
</el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from 'vue' import { nextTick, onBeforeUnmount, reactive, ref } from 'vue'
import { useStore } from 'vuex' 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 http from '@/utils/http' import http from '@/utils/http'
import {
createTianAiCaptcha,
type TianAiCaptchaConfig,
type TianAiCaptchaInstance,
type TianAiCaptchaResult
} from '@/vendor/tianai-captcha'
type UserInfo = { type LoginFormState = {
username: string | null, username: string | null,
password: string | null password: string | null
} }
type LoginRequest = LoginFormState & {
captchaId?: string,
captchaData?: Record<string, any>
}
type LoginConfigResponse = {
captcha?: {
enabled?: boolean
}
}
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 captchaDialogVisible = ref(false)
const captchaInstance = ref<TianAiCaptchaInstance | null>(null)
const requestCaptchaDataUrl = buildApiUrl('/captcha/gen')
const captchaErrorPattern = /验证码|captcha/i
const userInfo: UserInfo = reactive({ const userInfo: LoginFormState = reactive({
username: null, username: null,
password: null password: null
}) })
@ -77,19 +116,130 @@ store.commit('logout')
store.commit('clearTabs') store.commit('clearTabs')
localStorage.clear() localStorage.clear()
async function login() { async function openCaptchaDialog() {
captchaDialogVisible.value = true
await nextTick()
initCaptcha()
}
async function handleLogin() {
loginForm.value?.validate(async (valid: boolean) => { loginForm.value?.validate(async (valid: boolean) => {
if (!valid) return if (!valid || loading.value) return
loading.value = true loading.value = true
const data = await http.post<UserInfo, any>('/common/login', userInfo).finally(() => {
try {
const loginConfig = await http.get<any, LoginConfigResponse>('/common/loginConfig', {
params: {
username: userInfo.username,
}
})
if (loginConfig.captcha?.enabled) {
loading.value = false
await openCaptchaDialog()
return
}
await submitLogin(userInfo)
} catch {
loading.value = false loading.value = false
})
if (data.token) {
store.commit('login', data)
router.push('/')
} }
}) })
} }
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
loading.value = true
try {
await submitLogin({
...userInfo,
captchaId: result.id,
captchaData: result.data,
})
} catch (error) {
if (isCaptchaError(error)) {
captchaInstance.value?.reloadCaptcha()
} else {
closeCaptchaDialog()
}
} finally {
loading.value = false
}
}
async function submitLogin(payload: LoginRequest) {
const data = await http.post<LoginRequest, any>('/common/login', payload)
closeCaptchaDialog(false)
if (data.token) {
store.commit('login', data)
router.push('/')
}
}
function closeCaptchaDialog(resetLoading = true) {
captchaDialogVisible.value = false
destroyCaptcha(false)
if (resetLoading) {
loading.value = false
}
}
function handleCaptchaDialogClosed() {
destroyCaptcha(false)
}
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 isCaptchaError(error: unknown) {
if (error instanceof Error) {
return captchaErrorPattern.test(error.message)
}
return false
}
onBeforeUnmount(() => destroyCaptcha())
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.login-page { .login-page {
@ -227,4 +377,15 @@ async function login() {
border-radius: 8px; border-radius: 8px;
} }
} }
.captcha-dialog-subtitle {
margin: 0 0 16px;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.captcha-box {
width: fit-content;
margin: 0 auto;
}
</style> </style>