feat(auth): support captcha login
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user