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:
skytek_xinliang
2026-05-05 11:54:19 +08:00
parent 6eab4d9744
commit b37f4363eb
23 changed files with 1531 additions and 1588 deletions
+146 -1
View File
@@ -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 負責寫入/清除 tokenlogin/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,
}
})