修复退出登录并优化登录交互

This commit is contained in:
andy
2026-07-15 14:38:14 +07:00
parent 25da8a718e
commit d7b67fdcd5
7 changed files with 166 additions and 9 deletions

View File

@@ -94,6 +94,7 @@ export default {
dividersText: "Or", dividersText: "Or",
actions: { actions: {
sendCode: "Send code", sendCode: "Send code",
resendCodeCountdown: "Resend in {seconds}s",
refreshImageCode: "Refresh captcha", refreshImageCode: "Refresh captcha",
login: "Sign in", login: "Sign in",
}, },

View File

@@ -94,6 +94,7 @@ export default {
dividersText: "หรือ", dividersText: "หรือ",
actions: { actions: {
sendCode: "ส่งรหัส", sendCode: "ส่งรหัส",
resendCodeCountdown: "ส่งอีกครั้งใน {seconds}s",
refreshImageCode: "รีเฟรชแคปต์ชา", refreshImageCode: "รีเฟรชแคปต์ชา",
login: "เข้าสู่ระบบ", login: "เข้าสู่ระบบ",
}, },

View File

@@ -94,6 +94,7 @@ export default {
dividersText: "或", dividersText: "或",
actions: { actions: {
sendCode: "获取验证码", sendCode: "获取验证码",
resendCodeCountdown: "{seconds}s 后重发",
refreshImageCode: "刷新图形验证码", refreshImageCode: "刷新图形验证码",
login: "登录", login: "登录",
}, },

View File

@@ -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,

View File

@@ -32,9 +32,9 @@
: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" :loading="smsSending" :disabled="smsSending" <van-button class="rounded-full" size="small" :loading="smsSending" :disabled="!canSendSmsCode"
@click="handleSendCode"> @click="handleSendCode">
{{ t('common.login.actions.sendCode') }} {{ sendCodeText }}
</van-button> </van-button>
</template> </template>
</van-field> </van-field>
@@ -89,7 +89,7 @@
</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";
@@ -167,6 +167,8 @@ const imageRandomStr = ref(createCaptchaRandomStr());
const captchaImageUrl = ref(buildCaptchaImageUrl(imageRandomStr.value)); const captchaImageUrl = ref(buildCaptchaImageUrl(imageRandomStr.value));
const phoneSubmitting = ref(false); const phoneSubmitting = ref(false);
const smsSending = 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();
@@ -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 }) { function handleSelectCountry(item: { name: string; iso2: string; dialCode: string }) {
selectedCountry.value = item; selectedCountry.value = item;
countryPopupVisible.value = false; countryPopupVisible.value = false;
@@ -213,6 +235,26 @@ function refreshCaptchaImage() {
imageCode.value = ""; 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() { async function handleSendCode() {
const phoneDigits = phone.value.replace(/\D/g, ""); const phoneDigits = phone.value.replace(/\D/g, "");
const imageCodeValue = imageCode.value.trim(); const imageCodeValue = imageCode.value.trim();
@@ -235,6 +277,7 @@ async function handleSendCode() {
imageCode: imageCodeValue, imageCode: imageCodeValue,
}); });
showToast(t("common.login.tips.codeSent")); showToast(t("common.login.tips.codeSent"));
startSmsCountdown();
} 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")));
@@ -393,6 +436,10 @@ onMounted(() => {
console.error(e); console.error(e);
}); });
}); });
onUnmounted(() => {
clearSmsCountdownTimer();
});
</script> </script>
<style scoped> <style scoped>

View 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
View 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);
}