接入登录图形验证码流程
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { storeAuthTokens } from "@/constants/token";
|
||||
import type { OAuthTokenResponse } from "@/constants/token";
|
||||
import { getRequestContext, request, requestRaw } from "../utils/request";
|
||||
import { buildSendMobileCodeRequest } from "./loginSms";
|
||||
|
||||
// 获取oauth token
|
||||
export interface OauthTokenRequest {
|
||||
@@ -13,6 +14,11 @@ export interface OauthTokenRequest {
|
||||
[property: string]: any;
|
||||
}
|
||||
|
||||
export interface SendCodeOptions {
|
||||
imageRandomStr?: string;
|
||||
imageCode?: string;
|
||||
}
|
||||
|
||||
export function buildFormUrlEncodedParams(
|
||||
data: Record<string, unknown>,
|
||||
): 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 encoded = encodeURIComponent(value);
|
||||
const requestConfig = buildSendMobileCodeRequest({
|
||||
mobile: value,
|
||||
clientConfigId: getRequestContext().clientId ?? "",
|
||||
imageRandomStr: options.imageRandomStr,
|
||||
imageCode: options.imageCode,
|
||||
});
|
||||
|
||||
return request(
|
||||
{
|
||||
url: `/admin/platformUser/sendMobileCode/${encoded}`,
|
||||
method: "get",
|
||||
},
|
||||
{
|
||||
skipAuth: true,
|
||||
headers: {
|
||||
clientConfigId: getRequestContext().clientId ?? "",
|
||||
},
|
||||
},
|
||||
requestConfig.config,
|
||||
requestConfig.options,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
66
src/api/loginSms.test.ts
Normal file
66
src/api/loginSms.test.ts
Normal 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
101
src/api/loginSms.ts
Normal 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;
|
||||
}
|
||||
@@ -82,23 +82,28 @@ export default {
|
||||
fields: {
|
||||
country: "Country/Code",
|
||||
phone: "Phone",
|
||||
imageCode: "Captcha",
|
||||
code: "Code",
|
||||
},
|
||||
placeholders: {
|
||||
selectCountry: "Select country code",
|
||||
phone: "Enter phone number",
|
||||
imageCode: "Enter captcha",
|
||||
code: "Enter verification code",
|
||||
},
|
||||
dividersText: "Or",
|
||||
actions: {
|
||||
sendCode: "Send code",
|
||||
refreshImageCode: "Refresh captcha",
|
||||
login: "Sign in",
|
||||
},
|
||||
errors: {
|
||||
missingPhone: "Please enter your phone number",
|
||||
missingImageCode: "Please enter the captcha",
|
||||
missingCode: "Please enter the verification code",
|
||||
},
|
||||
tips: {
|
||||
codeSent: "Verification code sent",
|
||||
smsApiMissing: "SMS sending API is not connected",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -82,23 +82,28 @@ export default {
|
||||
fields: {
|
||||
country: "ประเทศ/รหัส",
|
||||
phone: "โทรศัพท์",
|
||||
imageCode: "แคปต์ชา",
|
||||
code: "รหัสยืนยัน",
|
||||
},
|
||||
placeholders: {
|
||||
selectCountry: "เลือกรหัสประเทศ",
|
||||
phone: "กรอกหมายเลขโทรศัพท์",
|
||||
imageCode: "กรอกแคปต์ชา",
|
||||
code: "กรอกรหัสยืนยัน",
|
||||
},
|
||||
dividersText: "หรือ",
|
||||
actions: {
|
||||
sendCode: "ส่งรหัส",
|
||||
refreshImageCode: "รีเฟรชแคปต์ชา",
|
||||
login: "เข้าสู่ระบบ",
|
||||
},
|
||||
errors: {
|
||||
missingPhone: "กรอกหมายเลขโทรศัพท์",
|
||||
missingImageCode: "กรอกแคปต์ชา",
|
||||
missingCode: "กรอกรหัสยืนยัน",
|
||||
},
|
||||
tips: {
|
||||
codeSent: "ส่งรหัสยืนยันแล้ว",
|
||||
smsApiMissing: "ยังไม่ได้เชื่อมต่อ API สำหรับส่ง SMS",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -82,23 +82,28 @@ export default {
|
||||
fields: {
|
||||
country: "国家/区号",
|
||||
phone: "手机号",
|
||||
imageCode: "图形验证码",
|
||||
code: "验证码",
|
||||
},
|
||||
placeholders: {
|
||||
selectCountry: "请选择国家/区号",
|
||||
phone: "请输入手机号",
|
||||
imageCode: "请输入图形验证码",
|
||||
code: "请输入验证码",
|
||||
},
|
||||
dividersText: "或",
|
||||
actions: {
|
||||
sendCode: "获取验证码",
|
||||
refreshImageCode: "刷新图形验证码",
|
||||
login: "登录",
|
||||
},
|
||||
errors: {
|
||||
missingPhone: "请输入手机号",
|
||||
missingImageCode: "请输入图形验证码",
|
||||
missingCode: "请输入验证码",
|
||||
},
|
||||
tips: {
|
||||
codeSent: "验证码已发送",
|
||||
smsApiMissing: "验证码发送接口未接入",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,11 +16,24 @@
|
||||
<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"
|
||||
: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"
|
||||
:label="t('common.login.fields.code')" :placeholder="t('common.login.placeholders.code')"
|
||||
autocomplete="one-time-code">
|
||||
<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') }}
|
||||
</van-button>
|
||||
</template>
|
||||
@@ -81,11 +94,16 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { showToast } from "vant";
|
||||
import { oauthToken, sendCode } from "@/api/login";
|
||||
import {
|
||||
buildCaptchaImageUrl,
|
||||
createCaptchaRandomStr,
|
||||
resolveLoginErrorMessage,
|
||||
} from "@/api/loginSms";
|
||||
import { NOTICE_EVENT_LOGIN_SUCCESS } from "@/constants/constant";
|
||||
import { COUNTRY_CALLING_CODES, findCountryCallingCode } from "@/constants/countryCallingCodes";
|
||||
import { getCurrentLocale, setLocale } from "@/i18n";
|
||||
import { emitter } from "@/utils/events";
|
||||
import { Globe } from '@lucide/vue'
|
||||
import { Globe, RefreshCw } from "@lucide/vue";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -144,7 +162,11 @@ const selectedCountry = ref(
|
||||
|
||||
const phone = ref("");
|
||||
const code = ref("");
|
||||
const imageCode = ref("");
|
||||
const imageRandomStr = ref(createCaptchaRandomStr());
|
||||
const captchaImageUrl = ref(buildCaptchaImageUrl(imageRandomStr.value));
|
||||
const phoneSubmitting = ref(false);
|
||||
const smsSending = ref(false);
|
||||
|
||||
const filteredCountries = computed(() => {
|
||||
const q = countrySearch.value.trim().toLowerCase();
|
||||
@@ -185,19 +207,40 @@ function handleSelectCountry(item: { name: string; iso2: string; dialCode: strin
|
||||
countrySearch.value = "";
|
||||
}
|
||||
|
||||
function refreshCaptchaImage() {
|
||||
imageRandomStr.value = createCaptchaRandomStr();
|
||||
captchaImageUrl.value = buildCaptchaImageUrl(imageRandomStr.value);
|
||||
imageCode.value = "";
|
||||
}
|
||||
|
||||
async function handleSendCode() {
|
||||
const phoneDigits = phone.value.replace(/\D/g, "");
|
||||
const imageCodeValue = imageCode.value.trim();
|
||||
|
||||
if (!phoneDigits) {
|
||||
showToast(t("common.login.errors.missingPhone"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageCodeValue) {
|
||||
showToast(t("common.login.errors.missingImageCode"));
|
||||
return;
|
||||
}
|
||||
|
||||
smsSending.value = true;
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
.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) {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
Reference in New Issue
Block a user