优化登录验证码锁定交互
This commit is contained in:
@@ -3,6 +3,8 @@ import { describe, it } from "node:test";
|
||||
import {
|
||||
buildCaptchaImageUrl,
|
||||
buildSendMobileCodeRequest,
|
||||
isImageCaptchaLocked,
|
||||
resolveImageCaptchaInteractionState,
|
||||
resolveLoginErrorMessage,
|
||||
} from "./loginSms.ts";
|
||||
|
||||
@@ -63,4 +65,46 @@ describe("login sms api helpers", () => {
|
||||
"网络错误",
|
||||
);
|
||||
});
|
||||
|
||||
it("locks image captcha while sms countdown is active", () => {
|
||||
assert.equal(isImageCaptchaLocked(60), true);
|
||||
assert.equal(isImageCaptchaLocked(1), true);
|
||||
assert.equal(isImageCaptchaLocked(0), false);
|
||||
});
|
||||
|
||||
it("resolves captcha field and refresh button interaction state", () => {
|
||||
assert.deepEqual(
|
||||
resolveImageCaptchaInteractionState({
|
||||
smsSending: false,
|
||||
smsCountdown: 60,
|
||||
}),
|
||||
{
|
||||
disabled: true,
|
||||
clearable: false,
|
||||
refreshDisabled: true,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveImageCaptchaInteractionState({
|
||||
smsSending: false,
|
||||
smsCountdown: 0,
|
||||
}),
|
||||
{
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
refreshDisabled: false,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveImageCaptchaInteractionState({
|
||||
smsSending: true,
|
||||
smsCountdown: 0,
|
||||
}),
|
||||
{
|
||||
disabled: true,
|
||||
clearable: false,
|
||||
refreshDisabled: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,17 @@ export interface CaptchaImageUrlOptions {
|
||||
cacheBust?: number | string;
|
||||
}
|
||||
|
||||
export interface ImageCaptchaInteractionParams {
|
||||
smsSending: boolean;
|
||||
smsCountdown: number;
|
||||
}
|
||||
|
||||
export interface ImageCaptchaInteractionState {
|
||||
disabled: boolean;
|
||||
clearable: boolean;
|
||||
refreshDisabled: boolean;
|
||||
}
|
||||
|
||||
function trimEndSlash(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -39,6 +50,23 @@ export function createCaptchaRandomStr(): string {
|
||||
return `${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function isImageCaptchaLocked(smsCountdown: number): boolean {
|
||||
return smsCountdown > 0;
|
||||
}
|
||||
|
||||
export function resolveImageCaptchaInteractionState({
|
||||
smsSending,
|
||||
smsCountdown,
|
||||
}: ImageCaptchaInteractionParams): ImageCaptchaInteractionState {
|
||||
const disabled = smsSending || isImageCaptchaLocked(smsCountdown);
|
||||
|
||||
return {
|
||||
disabled,
|
||||
clearable: !disabled,
|
||||
refreshDisabled: disabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCaptchaImageUrl(
|
||||
randomStr: string,
|
||||
options: CaptchaImageUrlOptions = {},
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
<van-field v-model="phone" type="tel" clearable :label="t('common.login.fields.phone')"
|
||||
:placeholder="t('common.login.placeholders.phone')" autocomplete="tel" />
|
||||
|
||||
<van-field v-model="imageCode" class="login-code-field" type="digit" clearable maxlength="6"
|
||||
<van-field v-model="imageCode" class="login-code-field" type="digit"
|
||||
:clearable="imageCaptchaInteraction.clearable"
|
||||
:disabled="imageCaptchaInteraction.disabled" maxlength="6"
|
||||
:label="t('common.login.fields.imageCode')" :placeholder="t('common.login.placeholders.imageCode')"
|
||||
autocomplete="off">
|
||||
<template #button>
|
||||
<button class="captcha-image-button" type="button" :aria-label="t('common.login.actions.refreshImageCode')"
|
||||
:disabled="imageCaptchaInteraction.refreshDisabled"
|
||||
@click="refreshCaptchaImage">
|
||||
<img v-if="captchaImageUrl" :src="captchaImageUrl" alt="" loading="lazy" />
|
||||
<RefreshCw v-else :size="18" />
|
||||
@@ -97,6 +100,7 @@ import { oauthToken, sendCode } from "@/api/login";
|
||||
import {
|
||||
buildCaptchaImageUrl,
|
||||
createCaptchaRandomStr,
|
||||
resolveImageCaptchaInteractionState,
|
||||
resolveLoginErrorMessage,
|
||||
} from "@/api/loginSms";
|
||||
import { NOTICE_EVENT_LOGIN_SUCCESS } from "@/constants/constant";
|
||||
@@ -213,6 +217,13 @@ const canSendSmsCode = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const imageCaptchaInteraction = computed(() => (
|
||||
resolveImageCaptchaInteractionState({
|
||||
smsSending: smsSending.value,
|
||||
smsCountdown: smsCountdown.value,
|
||||
})
|
||||
));
|
||||
|
||||
const sendCodeText = computed(() => {
|
||||
if (smsCountdown.value <= 0) {
|
||||
return t("common.login.actions.sendCode");
|
||||
@@ -229,12 +240,20 @@ function handleSelectCountry(item: { name: string; iso2: string; dialCode: strin
|
||||
countrySearch.value = "";
|
||||
}
|
||||
|
||||
function refreshCaptchaImage() {
|
||||
function resetCaptchaImage() {
|
||||
imageRandomStr.value = createCaptchaRandomStr();
|
||||
captchaImageUrl.value = buildCaptchaImageUrl(imageRandomStr.value);
|
||||
imageCode.value = "";
|
||||
}
|
||||
|
||||
function refreshCaptchaImage() {
|
||||
if (imageCaptchaInteraction.value.refreshDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetCaptchaImage();
|
||||
}
|
||||
|
||||
function clearSmsCountdownTimer() {
|
||||
if (smsCountdownTimer === null) {
|
||||
return;
|
||||
@@ -281,9 +300,9 @@ async function handleSendCode() {
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
showToast(resolveLoginErrorMessage(e, t("common.errors.network")));
|
||||
resetCaptchaImage();
|
||||
} finally {
|
||||
smsSending.value = false;
|
||||
refreshCaptchaImage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +503,11 @@ onUnmounted(() => {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.captcha-image-button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:deep(.country-search.van-search) {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
Reference in New Issue
Block a user