diff --git a/src/composables/useLoginCaptcha.ts b/src/composables/useLoginCaptcha.ts new file mode 100644 index 0000000..efed4c3 --- /dev/null +++ b/src/composables/useLoginCaptcha.ts @@ -0,0 +1,84 @@ +import type { CaptchaResponse } from '@/types/api' +import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue' +import { normalizeError } from '@/services/error' +import { authApi } from '@/services/modules/auth' + +interface UseLoginCaptchaOptions { + enabled: MaybeRefOrGetter +} + +export function useLoginCaptcha(options: UseLoginCaptchaOptions) { + const captcha = ref(null) + const captchaValue = ref('') + const captchaLoading = ref(false) + const captchaErrorMessage = ref(null) + const enabled = computed(() => toValue(options.enabled)) + + const formCaptcha = computed(() => { + if (!enabled.value || !captcha.value) return undefined + + return { + imgUrl: captcha.value.dntCaptchaImgUrl, + id: captcha.value.dntCaptchaId, + tokenValue: captcha.value.dntCaptchaTokenValue, + } + }) + + async function loadCaptcha() { + if (!enabled.value) return null + + captchaLoading.value = true + captchaErrorMessage.value = null + + try { + const { data } = await authApi.getCaptcha() + captcha.value = data + return data + } catch (error_) { + const normalizedError = normalizeError(error_) + captcha.value = null + captchaErrorMessage.value = normalizedError.message + throw normalizedError + } finally { + captchaLoading.value = false + } + } + + async function refreshCaptcha() { + if (!enabled.value) return null + + captchaValue.value = '' + return await loadCaptcha() + } + + function setCaptchaValue(value: string) { + if (!enabled.value) return + + captchaValue.value = value + } + + function getLoginCaptchaPayload() { + if (!enabled.value) return undefined + + if (!captcha.value?.dntCaptchaTextValue || !captcha.value?.dntCaptchaTokenValue) { + throw new Error('驗證碼資料缺失,請先刷新驗證碼') + } + + return { + DNTCaptchaInputText: captchaValue.value, + DNTCaptchaText: captcha.value.dntCaptchaTextValue, + DNTCaptchaToken: captcha.value.dntCaptchaTokenValue, + } + } + + return { + captchaValue, + captchaLoading, + captchaErrorMessage, + formCaptcha, + loadCaptcha, + refreshCaptcha, + setCaptchaValue, + getLoginCaptchaPayload, + } +} diff --git a/src/stores/auth.ts b/src/stores/auth.ts index f1c989c..1655603 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,4 +1,4 @@ -import type { CaptchaResponse, LoginPayload, User } from '@/types/api' +import type { LoginPayload, User } from '@/types/api' import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { normalizeError } from '@/services/error' @@ -16,32 +16,12 @@ export const useAuthStore = defineStore('auth', () => { const token = tokenService.token const loading = ref(false) const error = ref(null) - const captcha = ref(null) - const captchaLoading = ref(false) - const captchaErrorMessage = ref(null) // 只針對 login 取消重複請求,避免競態與重複提交 const loginController = ref(null) const isAuthenticated = computed(() => !!token.value) const roles = computed(() => (user.value?.role ? [user.value.role] : [])) - const getCaptcha = async () => { - captchaLoading.value = true - captchaErrorMessage.value = null - try { - const { data } = await authApi.getCaptcha() - captcha.value = data - return data - } catch (error_) { - const normalizedError = normalizeError(error_) - captcha.value = null - captchaErrorMessage.value = normalizedError.message - throw normalizedError - } finally { - captchaLoading.value = false - } - } - const login = async (payload: LoginPayload) => { loginController.value?.abort() loginController.value = new AbortController() @@ -49,24 +29,15 @@ export const useAuthStore = defineStore('auth', () => { error.value = null try { - if (!captcha.value?.dntCaptchaTextValue || !captcha.value?.dntCaptchaTokenValue) { - throw new Error('驗證碼資料缺失,請先刷新驗證碼') - } - - const requestPayload = { - UserID: payload.UserID, - Password: payload.Password, - DNTCaptchaInputText: payload.DNTCaptchaInputText, - DNTCaptchaText: captcha.value.dntCaptchaTextValue, - DNTCaptchaToken: captcha.value.dntCaptchaTokenValue, - } - const formData = new FormData() - formData.append('UserID', requestPayload.UserID) - formData.append('Password', requestPayload.Password) - formData.append('DNTCaptchaInputText', requestPayload.DNTCaptchaInputText) - formData.append('DNTCaptchaText', requestPayload.DNTCaptchaText) - formData.append('DNTCaptchaToken', requestPayload.DNTCaptchaToken) + formData.append('UserID', payload.UserID) + formData.append('Password', payload.Password) + + if (payload.captcha) { + formData.append('DNTCaptchaInputText', payload.captcha.DNTCaptchaInputText) + formData.append('DNTCaptchaText', payload.captcha.DNTCaptchaText) + formData.append('DNTCaptchaToken', payload.captcha.DNTCaptchaToken) + } const { data } = await authApi.login(formData, { signal: loginController.value.signal, @@ -130,10 +101,6 @@ export const useAuthStore = defineStore('auth', () => { } return { - getCaptcha, - captcha, - captchaLoading, - captchaErrorMessage, user, token, loading, diff --git a/src/types/api.ts b/src/types/api.ts index bdd491a..9ccb14d 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -16,8 +16,14 @@ export interface CaptchaResponse { dntCaptchaTextValue: string } +export interface LoginCaptchaPayload { + DNTCaptchaInputText: string + DNTCaptchaText: string + DNTCaptchaToken: string +} + export interface LoginPayload { UserID: string Password: string - DNTCaptchaInputText: string + captcha?: LoginCaptchaPayload } diff --git a/src/views/Login.vue b/src/views/Login.vue index 6ea2b00..d5837da 100644 --- a/src/views/Login.vue +++ b/src/views/Login.vue @@ -58,6 +58,7 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import HyakkaouAcademyImage from '@/assets/logo.png' import PageLogin from '@/components/PageLogin.vue' +import { useLoginCaptcha } from '@/composables/useLoginCaptcha' import { useAuthStore } from '@/stores/auth' import { type LoginAnnouncementListItem, @@ -94,6 +95,7 @@ const formPositionLayout = ref('side-left') // 功能開關:是否啟用驗證碼 const withCaptcha = ref(true) +const loginCaptcha = useLoginCaptcha({ enabled: withCaptcha }) // 文字內容(i18n) const title = computed(() => t('pages.login.title')) @@ -117,9 +119,6 @@ const forgotPasswordHref = ref('/forgot-password') const forgotPasswordTarget = ref(undefined) // 記住帳號的 localStorage key const rememberStorageKey = ref('login.remember.username') -// 驗證碼 API -const captchaValue = ref('') - // 驗證與對話框狀態 const dialogVisible = ref(false) const dialogTitle = ref('') @@ -155,16 +154,10 @@ const form = computed(() => ({ rememberStorageKey: rememberStorageKey.value, // 功能開關:是否顯示驗證碼 withCaptcha: withCaptcha.value, - captcha: authStore.captcha - ? { - imgUrl: authStore.captcha.dntCaptchaImgUrl, - id: authStore.captcha.dntCaptchaId, - tokenValue: authStore.captcha.dntCaptchaTokenValue, - } - : undefined, - captchaValue: captchaValue.value, - captchaLoading: authStore.captchaLoading, - captchaErrorMessage: authStore.captchaErrorMessage ?? '', + captcha: loginCaptcha.formCaptcha.value, + captchaValue: loginCaptcha.captchaValue.value, + captchaLoading: loginCaptcha.captchaLoading.value, + captchaErrorMessage: loginCaptcha.captchaErrorMessage.value ?? '', captchaVerified: false, forgotPassword: { text: forgotPasswordText.value, @@ -192,12 +185,11 @@ function handleChangeLocale(nextLocale: string) { } async function handleCaptchaRefresh() { - captchaValue.value = '' - await authStore.getCaptcha() + await loginCaptcha.refreshCaptcha().catch(() => undefined) } function handleCaptchaChange(value: string) { - captchaValue.value = value + loginCaptcha.setCaptchaValue(value) } function handleToggleLayout() { @@ -213,7 +205,7 @@ function handleSelectAnnouncement(item: LoginAnnouncementListItem) { } async function onLogin(data: Record) { - if (withCaptcha.value && !captchaValue.value) { + if (withCaptcha.value && !loginCaptcha.captchaValue.value) { dialogTitle.value = t('common.notice') dialogMessage.value = t('pages.login.alert.verifyRequired') dialogVisible.value = true @@ -234,7 +226,7 @@ async function onLogin(data: Record) { await authStore.login({ UserID: userId, Password: password, - DNTCaptchaInputText: captchaValue.value, + captcha: loginCaptcha.getLoginCaptchaPayload(), }) menuStore.getMenu(authStore.user?.id ?? '') @@ -262,6 +254,6 @@ async function onLogin(data: Record) { onMounted(() => { loginAnnouncementsStore.hydrate() loginAnnouncementsStore.fetchMobileAnnouncements() - authStore.getCaptcha() + void loginCaptcha.loadCaptcha().catch(() => undefined) })