Compare commits
3 Commits
99a0a483ca
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a2c222c2f | ||
|
|
d7b67fdcd5 | ||
|
|
25da8a718e |
@@ -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 ?? "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
110
src/api/loginSms.test.ts
Normal file
110
src/api/loginSms.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
buildCaptchaImageUrl,
|
||||||
|
buildSendMobileCodeRequest,
|
||||||
|
isImageCaptchaLocked,
|
||||||
|
resolveImageCaptchaInteractionState,
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
"网络错误",
|
||||||
|
),
|
||||||
|
"网络错误",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
129
src/api/loginSms.ts
Normal file
129
src/api/loginSms.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
export interface SendMobileCodeRequestParams {
|
||||||
|
mobile: string;
|
||||||
|
clientConfigId: string;
|
||||||
|
imageRandomStr?: string;
|
||||||
|
imageCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptchaImageUrlOptions {
|
||||||
|
baseURL?: 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 {
|
||||||
|
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 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 = {},
|
||||||
|
): 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,29 @@ 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",
|
||||||
|
resendCodeCountdown: "Resend in {seconds}s",
|
||||||
|
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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,23 +82,29 @@ 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: "ส่งรหัส",
|
||||||
|
resendCodeCountdown: "ส่งอีกครั้งใน {seconds}s",
|
||||||
|
refreshImageCode: "รีเฟรชแคปต์ชา",
|
||||||
login: "เข้าสู่ระบบ",
|
login: "เข้าสู่ระบบ",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
missingPhone: "กรอกหมายเลขโทรศัพท์",
|
missingPhone: "กรอกหมายเลขโทรศัพท์",
|
||||||
|
missingImageCode: "กรอกแคปต์ชา",
|
||||||
missingCode: "กรอกรหัสยืนยัน",
|
missingCode: "กรอกรหัสยืนยัน",
|
||||||
},
|
},
|
||||||
tips: {
|
tips: {
|
||||||
|
codeSent: "ส่งรหัสยืนยันแล้ว",
|
||||||
smsApiMissing: "ยังไม่ได้เชื่อมต่อ API สำหรับส่ง SMS",
|
smsApiMissing: "ยังไม่ได้เชื่อมต่อ API สำหรับส่ง SMS",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,23 +82,29 @@ 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: "获取验证码",
|
||||||
|
resendCodeCountdown: "{seconds}s 后重发",
|
||||||
|
refreshImageCode: "刷新图形验证码",
|
||||||
login: "登录",
|
login: "登录",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
missingPhone: "请输入手机号",
|
missingPhone: "请输入手机号",
|
||||||
|
missingImageCode: "请输入图形验证码",
|
||||||
missingCode: "请输入验证码",
|
missingCode: "请输入验证码",
|
||||||
},
|
},
|
||||||
tips: {
|
tips: {
|
||||||
|
codeSent: "验证码已发送",
|
||||||
smsApiMissing: "验证码发送接口未接入",
|
smsApiMissing: "验证码发送接口未接入",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,23 +33,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部退出按钮 -->
|
<!-- 底部退出按钮 -->
|
||||||
<span class="flex items-center justify-center h-[42px] mt-[40px] bg-white text-[#333] rounded-[4px] border-none"
|
<button type="button"
|
||||||
@click="handleLogout">{{ t("home.drawer.logout") }}</span>
|
class="flex items-center justify-center w-full h-[42px] mt-[40px] bg-white text-[#333] rounded-[4px] border-none active:bg-[#f5f5f5]"
|
||||||
|
@click="handleLogout">{{ t("home.drawer.logout") }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, defineEmits, defineExpose } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { checkToken } from "@/hooks/useGoLogin";
|
import { checkToken } from "@/hooks/useGoLogin";
|
||||||
|
import { goLogin } from "@/hooks/useNavigator";
|
||||||
import { getLoginUserPhone } from "@/api/login";
|
import { getLoginUserPhone } from "@/api/login";
|
||||||
import { NOTICE_EVENT_LOGOUT } from "@/constants/constant";
|
import { NOTICE_EVENT_LOGOUT } from "@/constants/constant";
|
||||||
import { getCurrentLocale, setLocale } from "@/i18n";
|
import { getCurrentLocale, setLocale } from "@/i18n";
|
||||||
import { useAppStore } from "@/store";
|
import { logoutLocalSession } from "@/utils/authSession";
|
||||||
|
import { emitter } from "@/utils/events";
|
||||||
|
import { setAuthToken } from "@/utils/request";
|
||||||
|
|
||||||
const appStore = useAppStore();
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const drawerRef = ref(null);
|
const drawerRef = ref(null);
|
||||||
|
|
||||||
@@ -105,7 +108,13 @@ const handleMenuClick = (item) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 退出登录
|
// 退出登录
|
||||||
const handleLogout = () => { };
|
const handleLogout = async () => {
|
||||||
|
logoutLocalSession(setAuthToken);
|
||||||
|
userInfo.value.phone = "";
|
||||||
|
close();
|
||||||
|
emitter.emit(NOTICE_EVENT_LOGOUT);
|
||||||
|
await goLogin();
|
||||||
|
};
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
open,
|
open,
|
||||||
|
|||||||
@@ -16,12 +16,28 @@
|
|||||||
<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="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" />
|
||||||
|
</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="!canSendSmsCode"
|
||||||
{{ t('common.login.actions.sendCode') }}
|
@click="handleSendCode">
|
||||||
|
{{ sendCodeText }}
|
||||||
</van-button>
|
</van-button>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
@@ -76,16 +92,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref } from "vue";
|
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
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,
|
||||||
|
resolveImageCaptchaInteractionState,
|
||||||
|
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 +166,13 @@ 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 smsCountdown = ref(0);
|
||||||
|
let smsCountdownTimer: ReturnType<typeof window.setInterval> | null = null;
|
||||||
|
|
||||||
const filteredCountries = computed(() => {
|
const filteredCountries = computed(() => {
|
||||||
const q = countrySearch.value.trim().toLowerCase();
|
const q = countrySearch.value.trim().toLowerCase();
|
||||||
@@ -179,25 +207,102 @@ const canSubmitPhoneLogin = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canSendSmsCode = computed(() => {
|
||||||
|
const phoneDigits = phone.value.replace(/\D/g, "");
|
||||||
|
return (
|
||||||
|
phoneDigits.length >= 6 &&
|
||||||
|
imageCode.value.trim().length > 0 &&
|
||||||
|
!smsSending.value &&
|
||||||
|
smsCountdown.value === 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageCaptchaInteraction = computed(() => (
|
||||||
|
resolveImageCaptchaInteractionState({
|
||||||
|
smsSending: smsSending.value,
|
||||||
|
smsCountdown: smsCountdown.value,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
const sendCodeText = computed(() => {
|
||||||
|
if (smsCountdown.value <= 0) {
|
||||||
|
return t("common.login.actions.sendCode");
|
||||||
|
}
|
||||||
|
|
||||||
|
return t("common.login.actions.resendCodeCountdown", {
|
||||||
|
seconds: smsCountdown.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function handleSelectCountry(item: { name: string; iso2: string; dialCode: string }) {
|
function handleSelectCountry(item: { name: string; iso2: string; dialCode: string }) {
|
||||||
selectedCountry.value = item;
|
selectedCountry.value = item;
|
||||||
countryPopupVisible.value = false;
|
countryPopupVisible.value = false;
|
||||||
countrySearch.value = "";
|
countrySearch.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
window.clearInterval(smsCountdownTimer);
|
||||||
|
smsCountdownTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSmsCountdown() {
|
||||||
|
clearSmsCountdownTimer();
|
||||||
|
smsCountdown.value = 60;
|
||||||
|
smsCountdownTimer = window.setInterval(() => {
|
||||||
|
smsCountdown.value -= 1;
|
||||||
|
if (smsCountdown.value <= 0) {
|
||||||
|
smsCountdown.value = 0;
|
||||||
|
clearSmsCountdownTimer();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
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"));
|
||||||
|
startSmsCountdown();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
showToast(t("common.errors.network"));
|
showToast(resolveLoginErrorMessage(e, t("common.errors.network")));
|
||||||
|
resetCaptchaImage();
|
||||||
|
} finally {
|
||||||
|
smsSending.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +455,10 @@ onMounted(() => {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearSmsCountdownTimer();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -375,6 +484,30 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|||||||
90
src/utils/authSession.test.ts
Normal file
90
src/utils/authSession.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
getAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
|
storeAuthTokens,
|
||||||
|
} from "../constants/token.ts";
|
||||||
|
import { logoutLocalSession } from "./authSession.ts";
|
||||||
|
|
||||||
|
type CookieEntry = {
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function installCookieDocument() {
|
||||||
|
const cookies = new Map<string, CookieEntry>();
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(globalThis, "document");
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "document", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
get cookie() {
|
||||||
|
return Array.from(cookies.entries())
|
||||||
|
.map(([name, entry]) => `${name}=${entry.value}`)
|
||||||
|
.join("; ");
|
||||||
|
},
|
||||||
|
set cookie(rawCookie: string) {
|
||||||
|
const [nameValue, ...attributes] = rawCookie
|
||||||
|
.split(";")
|
||||||
|
.map((item) => item.trim());
|
||||||
|
const [name, value] = nameValue.split("=");
|
||||||
|
const maxAge = attributes.find((item) =>
|
||||||
|
item.toLowerCase().startsWith("max-age="),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (maxAge?.toLowerCase() === "max-age=0") {
|
||||||
|
cookies.delete(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies.set(name, { value });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (previous) {
|
||||||
|
Object.defineProperty(globalThis, "document", previous);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Reflect.deleteProperty(globalThis, "document");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("auth session", () => {
|
||||||
|
let restoreDocument: () => void;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreDocument = installCookieDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
logoutLocalSession();
|
||||||
|
restoreDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears stored oauth tokens on local logout", () => {
|
||||||
|
storeAuthTokens(
|
||||||
|
{
|
||||||
|
access_token: "access.token",
|
||||||
|
refresh_token: "refresh.token",
|
||||||
|
expires_in: "120",
|
||||||
|
},
|
||||||
|
{ nowMs: 1_000, requireRefreshToken: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
logoutLocalSession();
|
||||||
|
|
||||||
|
assert.equal(getAccessToken(), null);
|
||||||
|
assert.equal(getRefreshToken(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets runtime auth token when a setter is provided", () => {
|
||||||
|
const values: Array<string | null> = [];
|
||||||
|
|
||||||
|
logoutLocalSession((token) => values.push(token));
|
||||||
|
|
||||||
|
assert.deepEqual(values, [null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
8
src/utils/authSession.ts
Normal file
8
src/utils/authSession.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { clearAuthTokens } from "../constants/token.ts";
|
||||||
|
|
||||||
|
export type AuthTokenSetter = (token: string | null) => void;
|
||||||
|
|
||||||
|
export function logoutLocalSession(setAuthToken?: AuthTokenSetter): void {
|
||||||
|
clearAuthTokens();
|
||||||
|
setAuthToken?.(null);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user