优化登录验证码锁定交互

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 { import {
buildCaptchaImageUrl, buildCaptchaImageUrl,
buildSendMobileCodeRequest, buildSendMobileCodeRequest,
isImageCaptchaLocked,
resolveImageCaptchaInteractionState,
resolveLoginErrorMessage, resolveLoginErrorMessage,
} from "./loginSms.ts"; } 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; cacheBust?: number | string;
} }
export interface ImageCaptchaInteractionParams {
smsSending: boolean;
smsCountdown: number;
}
export interface ImageCaptchaInteractionState {
disabled: boolean;
clearable: boolean;
refreshDisabled: boolean;
}
function trimEndSlash(value: string): string { function trimEndSlash(value: string): string {
return value.replace(/\/+$/, ""); return value.replace(/\/+$/, "");
} }
@@ -39,6 +50,23 @@ export function createCaptchaRandomStr(): string {
return `${Date.now()}${Math.random().toString(36).slice(2, 10)}`; 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( export function buildCaptchaImageUrl(
randomStr: string, randomStr: string,
options: CaptchaImageUrlOptions = {}, options: CaptchaImageUrlOptions = {},

View File

@@ -16,11 +16,14 @@
<van-field v-model="phone" type="tel" clearable :label="t('common.login.fields.phone')" <van-field v-model="phone" type="tel" clearable :label="t('common.login.fields.phone')"
:placeholder="t('common.login.placeholders.phone')" autocomplete="tel" /> :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')" :label="t('common.login.fields.imageCode')" :placeholder="t('common.login.placeholders.imageCode')"
autocomplete="off"> autocomplete="off">
<template #button> <template #button>
<button class="captcha-image-button" type="button" :aria-label="t('common.login.actions.refreshImageCode')" <button class="captcha-image-button" type="button" :aria-label="t('common.login.actions.refreshImageCode')"
:disabled="imageCaptchaInteraction.refreshDisabled"
@click="refreshCaptchaImage"> @click="refreshCaptchaImage">
<img v-if="captchaImageUrl" :src="captchaImageUrl" alt="" loading="lazy" /> <img v-if="captchaImageUrl" :src="captchaImageUrl" alt="" loading="lazy" />
<RefreshCw v-else :size="18" /> <RefreshCw v-else :size="18" />
@@ -97,6 +100,7 @@ import { oauthToken, sendCode } from "@/api/login";
import { import {
buildCaptchaImageUrl, buildCaptchaImageUrl,
createCaptchaRandomStr, createCaptchaRandomStr,
resolveImageCaptchaInteractionState,
resolveLoginErrorMessage, resolveLoginErrorMessage,
} from "@/api/loginSms"; } from "@/api/loginSms";
import { NOTICE_EVENT_LOGIN_SUCCESS } from "@/constants/constant"; 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(() => { const sendCodeText = computed(() => {
if (smsCountdown.value <= 0) { if (smsCountdown.value <= 0) {
return t("common.login.actions.sendCode"); return t("common.login.actions.sendCode");
@@ -229,12 +240,20 @@ function handleSelectCountry(item: { name: string; iso2: string; dialCode: strin
countrySearch.value = ""; countrySearch.value = "";
} }
function refreshCaptchaImage() { function resetCaptchaImage() {
imageRandomStr.value = createCaptchaRandomStr(); imageRandomStr.value = createCaptchaRandomStr();
captchaImageUrl.value = buildCaptchaImageUrl(imageRandomStr.value); captchaImageUrl.value = buildCaptchaImageUrl(imageRandomStr.value);
imageCode.value = ""; imageCode.value = "";
} }
function refreshCaptchaImage() {
if (imageCaptchaInteraction.value.refreshDisabled) {
return;
}
resetCaptchaImage();
}
function clearSmsCountdownTimer() { function clearSmsCountdownTimer() {
if (smsCountdownTimer === null) { if (smsCountdownTimer === null) {
return; return;
@@ -281,9 +300,9 @@ async function handleSendCode() {
} catch (e: unknown) { } catch (e: unknown) {
console.error(e); console.error(e);
showToast(resolveLoginErrorMessage(e, t("common.errors.network"))); showToast(resolveLoginErrorMessage(e, t("common.errors.network")));
resetCaptchaImage();
} finally { } finally {
smsSending.value = false; smsSending.value = false;
refreshCaptchaImage();
} }
} }
@@ -484,6 +503,11 @@ onUnmounted(() => {
object-fit: cover; object-fit: cover;
} }
.captcha-image-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
:deep(.country-search.van-search) { :deep(.country-search.van-search) {
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;