import type { CaptchaResponse, LoginPayload, User } from '@/types/api' import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { normalizeError } from '@/services/error' import { authApi } from '@/services/modules/auth' import { tokenService } from '@/services/token' import { useMenuStore } from '@/stores/menu' // - 只在 store 管理登入狀態:user/token/loading/error // - Component 不直接呼叫 API,避免狀態散落 // - token 單一來源:透過 tokenService 同步 ref + localStorage // - store 負責寫入/清除 token(login/logout) // - HTTP hooks 只讀 tokenService export const useAuthStore = defineStore('auth', () => { const user = ref(null) 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() loading.value = true 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) const { data } = await authApi.login(formData, { signal: loginController.value.signal, }) const parseUser = (val: unknown): User | undefined => { if (!val || typeof val !== 'object') return const obj = val as Record const id = obj.id const name = obj.name const role = obj.role if (typeof id !== 'string' || typeof name !== 'string' || typeof role !== 'string') return return { id, name, role } } const parseLoginResult = ( raw: unknown ): { accessToken?: string tokenType?: string expiresIn?: number user?: User message?: string } => { if (!raw || typeof raw !== 'object') return {} const obj = raw as Record const accessToken = typeof obj.access_token === 'string' ? obj.access_token : undefined const tokenType = typeof obj.token_type === 'string' ? obj.token_type : undefined const expiresIn = typeof obj.expires_in === 'number' ? obj.expires_in : undefined const user = parseUser(obj.user) const message = typeof obj.message === 'string' ? obj.message : undefined return { accessToken, tokenType, expiresIn, user, message } } const result = parseLoginResult(data) if (!result.accessToken) { throw new Error(result.message || '登入回傳缺少 access_token') } user.value = result.user ?? null tokenService.setToken(result.accessToken) } catch (error_) { const normalizedError = normalizeError(error_) if (normalizedError.name !== 'CanceledRequestError') { error.value = normalizedError.message } throw normalizedError } finally { loading.value = false loginController.value = null } } const logout = () => { user.value = null tokenService.clearToken() useMenuStore().clear() } return { getCaptcha, captcha, captchaLoading, captchaErrorMessage, user, token, loading, error, isAuthenticated, roles, login, logout, } })