优化登录验证码锁定交互

This commit is contained in:
andy
2026-07-15 14:59:16 +07:00
parent d7b67fdcd5
commit 2a2c222c2f
3 changed files with 99 additions and 3 deletions

View File

@@ -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,
},
);
});
});

View File

@@ -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 = {},

View File

@@ -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;