147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
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<User | null>(null)
|
||
const token = tokenService.token
|
||
const loading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
const captcha = ref<CaptchaResponse | null>(null)
|
||
const captchaLoading = ref(false)
|
||
const captchaErrorMessage = ref<string | null>(null)
|
||
// 只針對 login 取消重複請求,避免競態與重複提交
|
||
const loginController = ref<AbortController | null>(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<string, unknown>
|
||
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<string, unknown>
|
||
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,
|
||
}
|
||
})
|