feat(auth): support captcha login

This commit is contained in:
yelan
2026-09-01 14:50:29 +08:00
parent 5b2cc33038
commit 2963c6ea56
3 changed files with 121 additions and 4 deletions

View File

@@ -4,6 +4,7 @@ import { ApiResponseError, unwrapApiResponse } from '@/utils/api-response'
import { request } from '@/utils/request' import { request } from '@/utils/request'
import type { import type {
AuthenticatedUser, AuthenticatedUser,
CaptchaResponse,
CurrentUserResponse, CurrentUserResponse,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
@@ -12,6 +13,11 @@ import type { ApiResponse } from '@/types/common'
export { ApiResponseError } from '@/utils/api-response' export { ApiResponseError } from '@/utils/api-response'
export async function getCaptcha(): Promise<CaptchaResponse> {
const response = await request.get<ApiResponse<CaptchaResponse>>('/v1/auth/captcha')
return unwrapApiResponse(response)
}
function normalizePermissions(value: unknown): string[] { function normalizePermissions(value: unknown): string[] {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string') return value.filter((item): item is string => typeof item === 'string')

View File

@@ -5,6 +5,14 @@ export type AuthUserStatus = 'ENABLED' | 'DISABLED'
export interface LoginRequest { export interface LoginRequest {
username: string username: string
password: string password: string
captchaId?: string
captchaCode?: string
}
export interface CaptchaResponse {
captchaId: string
imageBase64: string
expiresAt: string
} }
export interface AuthenticatedUser { export interface AuthenticatedUser {

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getAuthErrorMessage } from '@/api/auth' import { getAuthErrorMessage, getCaptcha } from '@/api/auth'
import type { CaptchaResponse, LoginRequest } from '@/types/auth'
import { useLoginStore } from '@/stores/login' import { useLoginStore } from '@/stores/login'
const route = useRoute() const route = useRoute()
@@ -11,9 +12,27 @@ const loginStore = useLoginStore()
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const captcha = ref<CaptchaResponse | null>(null)
const captchaCode = ref('')
const captchaLoading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const isSubmitting = ref(false) const isSubmitting = ref(false)
async function loadCaptcha() {
captchaLoading.value = true
try {
captcha.value = await getCaptcha()
captchaCode.value = ''
} catch {
// 验证码由服务端按需启用;接口不可用时保持账号密码登录兼容。
captcha.value = null
captchaCode.value = ''
} finally {
captchaLoading.value = false
}
}
function getRedirectPath() { function getRedirectPath() {
const redirect = route.query.redirect const redirect = route.query.redirect
return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
@@ -29,22 +48,43 @@ async function handleLogin() {
return return
} }
if (captcha.value && !captchaCode.value.trim()) {
errorMessage.value = '请输入图形验证码'
return
}
errorMessage.value = '' errorMessage.value = ''
isSubmitting.value = true isSubmitting.value = true
try { try {
await loginStore.login({ const payload: LoginRequest = {
username: normalizedUsername, username: normalizedUsername,
password: password.value, password: password.value,
}) ...(captcha.value
? {
captchaId: captcha.value.captchaId,
captchaCode: captchaCode.value.trim(),
}
: {}),
}
await loginStore.login(payload)
await router.replace(getRedirectPath()) await router.replace(getRedirectPath())
} catch (error: unknown) { } catch (error: unknown) {
errorMessage.value = getAuthErrorMessage(error) errorMessage.value = getAuthErrorMessage(error)
if (captcha.value) {
void loadCaptcha()
}
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false
} }
} }
onMounted(() => {
void loadCaptcha()
})
</script> </script>
<template> <template>
@@ -98,6 +138,30 @@ async function handleLogin() {
/> />
</div> </div>
<div v-if="captcha" class="login-field login-captcha">
<label for="login-captcha">验证码</label>
<div class="captcha-row">
<input
id="login-captcha"
v-model="captchaCode"
type="text"
autocomplete="one-time-code"
inputmode="text"
maxlength="8"
placeholder="请输入验证码"
/>
<button
type="button"
class="captcha-image-button"
:disabled="captchaLoading"
aria-label="刷新验证码"
@click="loadCaptcha"
>
<img :src="captcha.imageBase64" alt="图形验证码,点击刷新" />
</button>
</div>
</div>
<p v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</p> <p v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</p>
<button class="login-submit" type="submit" :disabled="isSubmitting"> <button class="login-submit" type="submit" :disabled="isSubmitting">
@@ -192,6 +256,45 @@ async function handleLogin() {
border-color: var(--brand); border-color: var(--brand);
} }
.captcha-row {
display: flex;
gap: 8px;
align-items: center;
}
.captcha-row input {
flex: 1;
}
.captcha-image-button {
display: grid;
width: 112px;
height: 34px;
flex-shrink: 0;
padding: 0;
overflow: hidden;
background: #f4f7f9;
border: 1px solid var(--line);
border-radius: 6px;
place-items: center;
}
.captcha-image-button:hover:not(:disabled) {
border-color: var(--brand);
}
.captcha-image-button:disabled {
cursor: wait;
opacity: 0.6;
}
.captcha-image-button img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.login-error { .login-error {
margin: -4px 0 10px; margin: -4px 0 10px;
color: var(--err); color: var(--err);