修复退出登录并优化登录交互
This commit is contained in:
@@ -94,6 +94,7 @@ export default {
|
||||
dividersText: "Or",
|
||||
actions: {
|
||||
sendCode: "Send code",
|
||||
resendCodeCountdown: "Resend in {seconds}s",
|
||||
refreshImageCode: "Refresh captcha",
|
||||
login: "Sign in",
|
||||
},
|
||||
|
||||
@@ -94,6 +94,7 @@ export default {
|
||||
dividersText: "หรือ",
|
||||
actions: {
|
||||
sendCode: "ส่งรหัส",
|
||||
resendCodeCountdown: "ส่งอีกครั้งใน {seconds}s",
|
||||
refreshImageCode: "รีเฟรชแคปต์ชา",
|
||||
login: "เข้าสู่ระบบ",
|
||||
},
|
||||
|
||||
@@ -94,6 +94,7 @@ export default {
|
||||
dividersText: "或",
|
||||
actions: {
|
||||
sendCode: "获取验证码",
|
||||
resendCodeCountdown: "{seconds}s 后重发",
|
||||
refreshImageCode: "刷新图形验证码",
|
||||
login: "登录",
|
||||
},
|
||||
|
||||
@@ -33,23 +33,26 @@
|
||||
</div>
|
||||
|
||||
<!-- 底部退出按钮 -->
|
||||
<span class="flex items-center justify-center h-[42px] mt-[40px] bg-white text-[#333] rounded-[4px] border-none"
|
||||
@click="handleLogout">{{ t("home.drawer.logout") }}</span>
|
||||
<button type="button"
|
||||
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>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, defineEmits, defineExpose } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { checkToken } from "@/hooks/useGoLogin";
|
||||
import { goLogin } from "@/hooks/useNavigator";
|
||||
import { getLoginUserPhone } from "@/api/login";
|
||||
import { NOTICE_EVENT_LOGOUT } from "@/constants/constant";
|
||||
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 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({
|
||||
open,
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
: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" :loading="smsSending" :disabled="smsSending"
|
||||
<van-button class="rounded-full" size="small" :loading="smsSending" :disabled="!canSendSmsCode"
|
||||
@click="handleSendCode">
|
||||
{{ t('common.login.actions.sendCode') }}
|
||||
{{ sendCodeText }}
|
||||
</van-button>
|
||||
</template>
|
||||
</van-field>
|
||||
@@ -89,7 +89,7 @@
|
||||
</template>
|
||||
|
||||
<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 { useI18n } from "vue-i18n";
|
||||
import { showToast } from "vant";
|
||||
@@ -167,6 +167,8 @@ const imageRandomStr = ref(createCaptchaRandomStr());
|
||||
const captchaImageUrl = ref(buildCaptchaImageUrl(imageRandomStr.value));
|
||||
const phoneSubmitting = ref(false);
|
||||
const smsSending = ref(false);
|
||||
const smsCountdown = ref(0);
|
||||
let smsCountdownTimer: ReturnType<typeof window.setInterval> | null = null;
|
||||
|
||||
const filteredCountries = computed(() => {
|
||||
const q = countrySearch.value.trim().toLowerCase();
|
||||
@@ -201,6 +203,26 @@ 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 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 }) {
|
||||
selectedCountry.value = item;
|
||||
countryPopupVisible.value = false;
|
||||
@@ -213,6 +235,26 @@ function refreshCaptchaImage() {
|
||||
imageCode.value = "";
|
||||
}
|
||||
|
||||
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() {
|
||||
const phoneDigits = phone.value.replace(/\D/g, "");
|
||||
const imageCodeValue = imageCode.value.trim();
|
||||
@@ -235,6 +277,7 @@ async function handleSendCode() {
|
||||
imageCode: imageCodeValue,
|
||||
});
|
||||
showToast(t("common.login.tips.codeSent"));
|
||||
startSmsCountdown();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
showToast(resolveLoginErrorMessage(e, t("common.errors.network")));
|
||||
@@ -393,6 +436,10 @@ onMounted(() => {
|
||||
console.error(e);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearSmsCountdownTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
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