refactor: ky
This commit is contained in:
+65
-102
@@ -1,122 +1,85 @@
|
||||
import { type AxiosError, AxiosHeaders, type AxiosInstance } from 'axios'
|
||||
import type { Hooks } from 'ky'
|
||||
import { isHTTPError } from 'ky'
|
||||
import { extractErrorMessage, normalizeError } from './error'
|
||||
import { emitHttpError } from './http-error'
|
||||
import { emitHttpToast } from './http-toast'
|
||||
import { emitForceLogout } from './session'
|
||||
import { tokenService } from './token'
|
||||
|
||||
// Axios 攔截器
|
||||
//
|
||||
// 設計重點:
|
||||
// - Request:自動注入 token(從 localStorage 讀取)
|
||||
// - 使用 tokenService 作為單一來源,避免 interceptor 直接 import Pinia store 造成循環依賴
|
||||
// store(auth) -> services(authApi/menuApi) -> httpClient -> interceptors -> store(auth)
|
||||
// - Response:統一處理 HTTP 錯誤(目前示範 401/403/500)
|
||||
// - 使用 normalizeError 將錯誤轉成 ApiRequestError
|
||||
//
|
||||
// 注意:
|
||||
// - Store 仍然是唯一負責「寫入/清除 token」的地方(login/logout)
|
||||
// - Interceptor 只負責「讀取 token 並附加到 request」
|
||||
export function setupInterceptors(client: AxiosInstance) {
|
||||
// Request: 自動注入 token
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = tokenService.getToken()
|
||||
const url = config.url ?? ''
|
||||
const shouldAttachToken = !url.includes('/Auth/login')
|
||||
export function createHooks(): Hooks {
|
||||
return {
|
||||
beforeRequest: [
|
||||
({ request }) => {
|
||||
const token = tokenService.getToken()
|
||||
const url = request.url
|
||||
const shouldAttachToken = !url.includes('/Auth/login')
|
||||
|
||||
if (token && shouldAttachToken) {
|
||||
const headers = AxiosHeaders.from(config.headers ?? {})
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
config.headers = headers
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
if (token && shouldAttachToken) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeError: [
|
||||
({ request, options, error }) => {
|
||||
const normalized = normalizeError(error)
|
||||
|
||||
// Response: 統一錯誤處理
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
const normalized = normalizeError(error)
|
||||
if (normalized.name === 'CanceledRequestError') {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// 取消請求不做全域錯誤導頁
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return Promise.reject(normalized)
|
||||
}
|
||||
const requestUrl = request.url
|
||||
const isLoginRequest = requestUrl.includes('/Auth/login')
|
||||
const silentToast = Boolean(options.context.silentToast)
|
||||
const status = isHTTPError(error) ? error.response.status : undefined
|
||||
|
||||
const requestUrl = error.config?.url ?? ''
|
||||
const isLoginRequest = requestUrl.includes('/Auth/login')
|
||||
const silentToast = Boolean(error.config?.meta?.silentToast)
|
||||
switch (status) {
|
||||
case 401: {
|
||||
if (requestUrl.includes('/Auth/login')) break
|
||||
|
||||
// 統一處理 HTTP 狀態碼錯誤
|
||||
const status = error.response?.status
|
||||
switch (status) {
|
||||
// 401 Unauthorized
|
||||
case 401: {
|
||||
// 不是所有 401 都代表「token 過期」:
|
||||
// - 登入失敗通常也會是 401,但不應觸發全域登出流程
|
||||
// 這裡以「是否帶 Authorization header」作為判斷依據
|
||||
{
|
||||
const url = error.config?.url ?? ''
|
||||
if (url.includes('/Auth/login')) break
|
||||
|
||||
const requestHeaders = AxiosHeaders.from(error.config?.headers ?? {})
|
||||
const hasAuthHeader = Boolean(requestHeaders.get('Authorization'))
|
||||
const hasAuthHeader = request.headers.has('Authorization')
|
||||
if (hasAuthHeader) {
|
||||
tokenService.clearToken()
|
||||
const backendMessage = extractErrorMessage(error.response?.data)
|
||||
const backendMessage = isHTTPError(error) ? extractErrorMessage(error.data) : undefined
|
||||
emitForceLogout({ message: backendMessage })
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
case 403: {
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
case 404: {
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
case 500: {
|
||||
break
|
||||
}
|
||||
case 503: {
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
default:
|
||||
}
|
||||
// 403 Forbidden
|
||||
case 403: {
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
// 404 Not Found(API 端點不存在/被移除)
|
||||
case 404: {
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
// 500 Internal Server Error
|
||||
case 500: {
|
||||
// 500 通常是「單一 API 失敗」:交由呼叫端決定 UI(snackbar/區塊錯誤/重試)
|
||||
// 避免同一頁多支 API 時,其中一支 500 就把整個頁面導走
|
||||
break
|
||||
}
|
||||
// 503 Service Unavailable
|
||||
case 503: {
|
||||
// 503 通常對使用者來說就是「系統維護/暫時無法使用」
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
default:
|
||||
// 無 response status 時,多半是網路/跨網域/連線問題:
|
||||
// 交由呼叫端決定 UI(snackbar/區塊錯誤/重試),避免全域導頁打斷使用者操作
|
||||
}
|
||||
|
||||
const shouldToast =
|
||||
!silentToast &&
|
||||
!isLoginRequest &&
|
||||
status !== 401 &&
|
||||
status !== 403 &&
|
||||
status !== 404 &&
|
||||
status !== 503 &&
|
||||
(status === 500 || !status || (typeof status === 'number' && status >= 500))
|
||||
const shouldToast =
|
||||
!silentToast &&
|
||||
!isLoginRequest &&
|
||||
status !== 401 &&
|
||||
status !== 403 &&
|
||||
status !== 404 &&
|
||||
status !== 503 &&
|
||||
(status === 500 || !status || (typeof status === 'number' && status >= 500))
|
||||
|
||||
if (shouldToast) {
|
||||
emitHttpToast({
|
||||
level: status ? 'error' : 'warning',
|
||||
message: normalized.message,
|
||||
})
|
||||
}
|
||||
return Promise.reject(normalized)
|
||||
}
|
||||
)
|
||||
if (shouldToast) {
|
||||
emitHttpToast({
|
||||
level: status ? 'error' : 'warning',
|
||||
message: normalized.message,
|
||||
})
|
||||
}
|
||||
|
||||
return normalized
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user