feat(stores): add Pinia domain stores and update docs
Implement concrete Pinia stores for app UI and domain data instead of placeholder re-exports, including seeded student records and snackbar state. Refresh README guidance for components, plugins, and services to document the current project structure, data flow, and usage conventions.feat(stores): add Pinia domain stores and update docs Implement concrete Pinia stores for app UI and domain data instead of placeholder re-exports, including seeded student records and snackbar state. Refresh README guidance for components, plugins, and services to document the current project structure, data flow, and usage conventions.
This commit is contained in:
+146
-1
@@ -1 +1,146 @@
|
||||
export * from './stores/auth'
|
||||
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)
|
||||
// - axios interceptor 只讀 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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user