接入登录图形验证码流程

This commit is contained in:
andy
2026-07-15 14:16:14 +07:00
parent 99a0a483ca
commit 25da8a718e
7 changed files with 263 additions and 16 deletions

View File

@@ -1,6 +1,7 @@
import { storeAuthTokens } from "@/constants/token"; import { storeAuthTokens } from "@/constants/token";
import type { OAuthTokenResponse } from "@/constants/token"; import type { OAuthTokenResponse } from "@/constants/token";
import { getRequestContext, request, requestRaw } from "../utils/request"; import { getRequestContext, request, requestRaw } from "../utils/request";
import { buildSendMobileCodeRequest } from "./loginSms";
// 获取oauth token // 获取oauth token
export interface OauthTokenRequest { export interface OauthTokenRequest {
@@ -13,6 +14,11 @@ export interface OauthTokenRequest {
[property: string]: any; [property: string]: any;
} }
export interface SendCodeOptions {
imageRandomStr?: string;
imageCode?: string;
}
export function buildFormUrlEncodedParams( export function buildFormUrlEncodedParams(
data: Record<string, unknown>, data: Record<string, unknown>,
): URLSearchParams { ): URLSearchParams {
@@ -61,21 +67,18 @@ export async function oauthToken(
} }
// 发送手机验证码 // 发送手机验证码
export function sendCode(mobile: string) { export function sendCode(mobile: string, options: SendCodeOptions = {}) {
const value = (mobile ?? "").trim(); const value = (mobile ?? "").trim();
const encoded = encodeURIComponent(value); const requestConfig = buildSendMobileCodeRequest({
mobile: value,
clientConfigId: getRequestContext().clientId ?? "",
imageRandomStr: options.imageRandomStr,
imageCode: options.imageCode,
});
return request( return request(
{ requestConfig.config,
url: `/admin/platformUser/sendMobileCode/${encoded}`, requestConfig.options,
method: "get",
},
{
skipAuth: true,
headers: {
clientConfigId: getRequestContext().clientId ?? "",
},
},
); );
} }

66
src/api/loginSms.test.ts Normal file
View File

@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCaptchaImageUrl,
buildSendMobileCodeRequest,
resolveLoginErrorMessage,
} from "./loginSms.ts";
describe("login sms api helpers", () => {
it("builds send-code request with image captcha params and clientConfigId header", () => {
const result = buildSendMobileCodeRequest({
mobile: "+8613800000000",
clientConfigId: "6",
imageRandomStr: "captcha-random",
imageCode: "42",
});
assert.deepEqual(result.config, {
url: "/admin/platformUser/sendMobileCode/%2B8613800000000",
method: "get",
params: {
imageRandomStr: "captcha-random",
imageCode: "42",
},
});
assert.deepEqual(result.options, {
skipAuth: true,
headers: {
clientConfigId: "6",
},
});
});
it("builds captcha image url from api base url", () => {
assert.equal(
buildCaptchaImageUrl("captcha random", {
baseURL: "/ingress/",
cacheBust: 123,
}),
"/ingress/auth/code/image?randomStr=captcha+random&_t=123",
);
});
it("uses backend business error message before generic fallback", () => {
assert.equal(
resolveLoginErrorMessage(
{
kind: "business",
message: "图形验证码不合法",
},
"网络错误",
),
"图形验证码不合法",
);
assert.equal(
resolveLoginErrorMessage(
{
kind: "network",
message: "Network Error",
},
"网络错误",
),
"网络错误",
);
});
});

101
src/api/loginSms.ts Normal file
View File

@@ -0,0 +1,101 @@
export interface SendMobileCodeRequestParams {
mobile: string;
clientConfigId: string;
imageRandomStr?: string;
imageCode?: string;
}
export interface CaptchaImageUrlOptions {
baseURL?: string;
cacheBust?: number | string;
}
function trimEndSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function resolveApiBaseURL(): string {
const env = (import.meta as ImportMeta & { env?: Record<string, unknown> }).env;
const value = env?.VITE_API_BASE_URL;
return typeof value === "string" ? value.trim() : "";
}
function appendIfPresent(
params: Record<string, string>,
key: string,
value?: string,
) {
const normalized = value?.trim();
if (normalized) {
params[key] = normalized;
}
}
export function createCaptchaRandomStr(): string {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
}
export function buildCaptchaImageUrl(
randomStr: string,
options: CaptchaImageUrlOptions = {},
): string {
const query = new URLSearchParams({
randomStr,
_t: String(options.cacheBust ?? Date.now()),
});
const baseURL = trimEndSlash(options.baseURL ?? resolveApiBaseURL());
return `${baseURL}/auth/code/image?${query.toString()}`;
}
export function buildSendMobileCodeRequest({
mobile,
clientConfigId,
imageRandomStr,
imageCode,
}: SendMobileCodeRequestParams) {
const params: Record<string, string> = {};
appendIfPresent(params, "imageRandomStr", imageRandomStr);
appendIfPresent(params, "imageCode", imageCode);
return {
config: {
url: `/admin/platformUser/sendMobileCode/${encodeURIComponent(
mobile.trim(),
)}`,
method: "get" as const,
params: Object.keys(params).length > 0 ? params : undefined,
},
options: {
skipAuth: true,
headers: {
clientConfigId,
},
},
};
}
export function resolveLoginErrorMessage(
error: unknown,
fallback: string,
): string {
if (!error || typeof error !== "object") {
return fallback;
}
const value = error as Record<string, unknown>;
const message = value.message;
if (
value.kind === "business" &&
typeof message === "string" &&
message.trim()
) {
return message;
}
return fallback;
}

View File

@@ -82,23 +82,28 @@ export default {
fields: { fields: {
country: "Country/Code", country: "Country/Code",
phone: "Phone", phone: "Phone",
imageCode: "Captcha",
code: "Code", code: "Code",
}, },
placeholders: { placeholders: {
selectCountry: "Select country code", selectCountry: "Select country code",
phone: "Enter phone number", phone: "Enter phone number",
imageCode: "Enter captcha",
code: "Enter verification code", code: "Enter verification code",
}, },
dividersText: "Or", dividersText: "Or",
actions: { actions: {
sendCode: "Send code", sendCode: "Send code",
refreshImageCode: "Refresh captcha",
login: "Sign in", login: "Sign in",
}, },
errors: { errors: {
missingPhone: "Please enter your phone number", missingPhone: "Please enter your phone number",
missingImageCode: "Please enter the captcha",
missingCode: "Please enter the verification code", missingCode: "Please enter the verification code",
}, },
tips: { tips: {
codeSent: "Verification code sent",
smsApiMissing: "SMS sending API is not connected", smsApiMissing: "SMS sending API is not connected",
}, },
}, },

View File

@@ -82,23 +82,28 @@ export default {
fields: { fields: {
country: "ประเทศ/รหัส", country: "ประเทศ/รหัส",
phone: "โทรศัพท์", phone: "โทรศัพท์",
imageCode: "แคปต์ชา",
code: "รหัสยืนยัน", code: "รหัสยืนยัน",
}, },
placeholders: { placeholders: {
selectCountry: "เลือกรหัสประเทศ", selectCountry: "เลือกรหัสประเทศ",
phone: "กรอกหมายเลขโทรศัพท์", phone: "กรอกหมายเลขโทรศัพท์",
imageCode: "กรอกแคปต์ชา",
code: "กรอกรหัสยืนยัน", code: "กรอกรหัสยืนยัน",
}, },
dividersText: "หรือ", dividersText: "หรือ",
actions: { actions: {
sendCode: "ส่งรหัส", sendCode: "ส่งรหัส",
refreshImageCode: "รีเฟรชแคปต์ชา",
login: "เข้าสู่ระบบ", login: "เข้าสู่ระบบ",
}, },
errors: { errors: {
missingPhone: "กรอกหมายเลขโทรศัพท์", missingPhone: "กรอกหมายเลขโทรศัพท์",
missingImageCode: "กรอกแคปต์ชา",
missingCode: "กรอกรหัสยืนยัน", missingCode: "กรอกรหัสยืนยัน",
}, },
tips: { tips: {
codeSent: "ส่งรหัสยืนยันแล้ว",
smsApiMissing: "ยังไม่ได้เชื่อมต่อ API สำหรับส่ง SMS", smsApiMissing: "ยังไม่ได้เชื่อมต่อ API สำหรับส่ง SMS",
}, },
}, },

View File

@@ -82,23 +82,28 @@ export default {
fields: { fields: {
country: "国家/区号", country: "国家/区号",
phone: "手机号", phone: "手机号",
imageCode: "图形验证码",
code: "验证码", code: "验证码",
}, },
placeholders: { placeholders: {
selectCountry: "请选择国家/区号", selectCountry: "请选择国家/区号",
phone: "请输入手机号", phone: "请输入手机号",
imageCode: "请输入图形验证码",
code: "请输入验证码", code: "请输入验证码",
}, },
dividersText: "或", dividersText: "或",
actions: { actions: {
sendCode: "获取验证码", sendCode: "获取验证码",
refreshImageCode: "刷新图形验证码",
login: "登录", login: "登录",
}, },
errors: { errors: {
missingPhone: "请输入手机号", missingPhone: "请输入手机号",
missingImageCode: "请输入图形验证码",
missingCode: "请输入验证码", missingCode: "请输入验证码",
}, },
tips: { tips: {
codeSent: "验证码已发送",
smsApiMissing: "验证码发送接口未接入", smsApiMissing: "验证码发送接口未接入",
}, },
}, },

View File

@@ -16,11 +16,24 @@
<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"
: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')"
@click="refreshCaptchaImage">
<img v-if="captchaImageUrl" :src="captchaImageUrl" alt="" loading="lazy" />
<RefreshCw v-else :size="18" />
</button>
</template>
</van-field>
<van-field v-model="code" class="login-code-field" type="digit" clearable maxlength="8" <van-field v-model="code" class="login-code-field" type="digit" clearable maxlength="8"
:label="t('common.login.fields.code')" :placeholder="t('common.login.placeholders.code')" :label="t('common.login.fields.code')" :placeholder="t('common.login.placeholders.code')"
autocomplete="one-time-code"> autocomplete="one-time-code">
<template #button> <template #button>
<van-button class="rounded-full" size="small" @click="handleSendCode"> <van-button class="rounded-full" size="small" :loading="smsSending" :disabled="smsSending"
@click="handleSendCode">
{{ t('common.login.actions.sendCode') }} {{ t('common.login.actions.sendCode') }}
</van-button> </van-button>
</template> </template>
@@ -81,11 +94,16 @@ import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { showToast } from "vant"; import { showToast } from "vant";
import { oauthToken, sendCode } from "@/api/login"; import { oauthToken, sendCode } from "@/api/login";
import {
buildCaptchaImageUrl,
createCaptchaRandomStr,
resolveLoginErrorMessage,
} from "@/api/loginSms";
import { NOTICE_EVENT_LOGIN_SUCCESS } from "@/constants/constant"; import { NOTICE_EVENT_LOGIN_SUCCESS } from "@/constants/constant";
import { COUNTRY_CALLING_CODES, findCountryCallingCode } from "@/constants/countryCallingCodes"; import { COUNTRY_CALLING_CODES, findCountryCallingCode } from "@/constants/countryCallingCodes";
import { getCurrentLocale, setLocale } from "@/i18n"; import { getCurrentLocale, setLocale } from "@/i18n";
import { emitter } from "@/utils/events"; import { emitter } from "@/utils/events";
import { Globe } from '@lucide/vue' import { Globe, RefreshCw } from "@lucide/vue";
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -144,7 +162,11 @@ const selectedCountry = ref(
const phone = ref(""); const phone = ref("");
const code = ref(""); const code = ref("");
const imageCode = ref("");
const imageRandomStr = ref(createCaptchaRandomStr());
const captchaImageUrl = ref(buildCaptchaImageUrl(imageRandomStr.value));
const phoneSubmitting = ref(false); const phoneSubmitting = ref(false);
const smsSending = ref(false);
const filteredCountries = computed(() => { const filteredCountries = computed(() => {
const q = countrySearch.value.trim().toLowerCase(); const q = countrySearch.value.trim().toLowerCase();
@@ -185,19 +207,40 @@ function handleSelectCountry(item: { name: string; iso2: string; dialCode: strin
countrySearch.value = ""; countrySearch.value = "";
} }
function refreshCaptchaImage() {
imageRandomStr.value = createCaptchaRandomStr();
captchaImageUrl.value = buildCaptchaImageUrl(imageRandomStr.value);
imageCode.value = "";
}
async function handleSendCode() { async function handleSendCode() {
const phoneDigits = phone.value.replace(/\D/g, ""); const phoneDigits = phone.value.replace(/\D/g, "");
const imageCodeValue = imageCode.value.trim();
if (!phoneDigits) { if (!phoneDigits) {
showToast(t("common.login.errors.missingPhone")); showToast(t("common.login.errors.missingPhone"));
return; return;
} }
if (!imageCodeValue) {
showToast(t("common.login.errors.missingImageCode"));
return;
}
smsSending.value = true;
try { try {
await sendCode(`${selectedCountry.value.dialCode}${phoneDigits}`); await sendCode(`${selectedCountry.value.dialCode}${phoneDigits}`, {
imageRandomStr: imageRandomStr.value,
imageCode: imageCodeValue,
});
showToast(t("common.login.tips.codeSent"));
} catch (e: unknown) { } catch (e: unknown) {
console.error(e); console.error(e);
showToast(t("common.errors.network")); showToast(resolveLoginErrorMessage(e, t("common.errors.network")));
} finally {
smsSending.value = false;
refreshCaptchaImage();
} }
} }
@@ -375,6 +418,25 @@ onMounted(() => {
padding-bottom: 0; padding-bottom: 0;
} }
.captcha-image-button {
width: 92px;
height: 32px;
padding: 0;
border: 0;
border-radius: 16px;
overflow: hidden;
background: #f7f8fa;
display: flex;
align-items: center;
justify-content: center;
}
.captcha-image-button img {
width: 100%;
height: 100%;
object-fit: cover;
}
:deep(.country-search.van-search) { :deep(.country-search.van-search) {
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;