feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { setupInterceptors } from './interceptors'
|
||||
|
||||
// HTTP Client(Axios instance)
|
||||
//
|
||||
// 設計重點:
|
||||
// - 透過單一 axios instance 統一管理 baseURL、timeout、headers 與攔截器
|
||||
// - 預設 baseURL 使用 `/api`:搭配 Vite proxy 轉送到後端 dev server
|
||||
// - 攔截器集中在 `interceptors.ts`,避免 client.ts 變得難維護
|
||||
function createClient (): AxiosInstance {
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '/service/api'
|
||||
const client = axios.create({
|
||||
baseURL,
|
||||
timeout: 10_000,
|
||||
withCredentials: true,
|
||||
})
|
||||
setupInterceptors(client)
|
||||
return client
|
||||
}
|
||||
|
||||
export const httpClient = createClient()
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { ApiError } from '@/types/api'
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
function isRecord (value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function firstString (value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = firstString(item)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const message = firstString(value.message)
|
||||
if (message) return message
|
||||
|
||||
// 常見錯誤格式:{ error: '...' } / { error: { message: '...' } }
|
||||
const errorValue = value.error
|
||||
const errorMessage = firstString(errorValue)
|
||||
if (errorMessage) return errorMessage
|
||||
|
||||
// RFC 7807 (problem+json): { title, detail }
|
||||
const detail = firstString(value.detail)
|
||||
if (detail) return detail
|
||||
const title = firstString(value.title)
|
||||
if (title) return title
|
||||
|
||||
// 有些後端用 msg
|
||||
const msg = firstString(value.msg)
|
||||
if (msg) return msg
|
||||
|
||||
// errors: Record<string, string[]>
|
||||
const errors = value.errors
|
||||
if (isRecord(errors)) {
|
||||
for (const key of Object.keys(errors)) {
|
||||
const found = firstString(errors[key])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function extractErrorMessage (data: unknown): string | undefined {
|
||||
return firstString(data)
|
||||
}
|
||||
|
||||
// 統一錯誤格式
|
||||
//
|
||||
// 設計重點:
|
||||
// - 將 AxiosError 與非預期錯誤統一轉成 ApiRequestError
|
||||
// - Store 只需要處理 message/code/status,不需理解 Axios 結構
|
||||
// - 取消請求(AbortController)會轉成 CanceledRequestError
|
||||
export class ApiRequestError extends Error {
|
||||
code?: number
|
||||
status?: number
|
||||
errors?: ApiError['errors']
|
||||
raw?: unknown
|
||||
|
||||
constructor(params: {
|
||||
message: string
|
||||
code?: number
|
||||
status?: number
|
||||
errors?: ApiError['errors']
|
||||
raw?: unknown
|
||||
}) {
|
||||
super(params.message)
|
||||
this.name = 'ApiRequestError'
|
||||
this.code = params.code
|
||||
this.status = params.status
|
||||
this.errors = params.errors
|
||||
this.raw = params.raw
|
||||
}
|
||||
}
|
||||
|
||||
export class CanceledRequestError extends ApiRequestError {
|
||||
constructor() {
|
||||
super({ message: '請求已取消' })
|
||||
this.name = 'CanceledRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isRequestCanceled (error: unknown): boolean {
|
||||
if (isAxiosError(error)) {
|
||||
return error.code === 'ERR_CANCELED'
|
||||
}
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
export function normalizeError (error: unknown): ApiRequestError {
|
||||
if (error instanceof ApiRequestError) {
|
||||
return error
|
||||
}
|
||||
|
||||
if (isRequestCanceled(error)) {
|
||||
return new CanceledRequestError()
|
||||
}
|
||||
|
||||
if (isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
const data = error.response?.data as unknown
|
||||
const message = extractErrorMessage(data) || error.message || '請求失敗'
|
||||
const apiError = isRecord(data) ? (data as Partial<ApiError>) : undefined
|
||||
const code = apiError?.code ?? status
|
||||
return new ApiRequestError({
|
||||
message,
|
||||
code,
|
||||
status,
|
||||
errors: apiError?.errors,
|
||||
raw: error
|
||||
})
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new ApiRequestError({ message: error.message, raw: error })
|
||||
}
|
||||
|
||||
return new ApiRequestError({ message: '未知錯誤', raw: error })
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 全域 HTTP 錯誤事件(Playground 專用)
|
||||
//
|
||||
// 目的:
|
||||
// - 避免 axios interceptor 直接 import router 造成耦合
|
||||
// - 由 router 層或 App 層決定要導到哪個錯誤頁與顯示哪些訊息
|
||||
|
||||
export const HTTP_ERROR_EVENT = 'sk-playground:http-error'
|
||||
|
||||
export type HttpErrorDetail = {
|
||||
status?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
let httpErrorEmitted = false
|
||||
|
||||
export function emitHttpError (detail: HttpErrorDetail) {
|
||||
// 避免同一波大量錯誤觸發多次導頁
|
||||
if (httpErrorEmitted) return
|
||||
httpErrorEmitted = true
|
||||
|
||||
window.dispatchEvent(new CustomEvent<HttpErrorDetail>(HTTP_ERROR_EVENT, { detail }))
|
||||
|
||||
// 下一個 event loop 再允許觸發(避免把 guard 永久鎖住)
|
||||
setTimeout(() => {
|
||||
httpErrorEmitted = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// 全域 HTTP Toast 事件(Playground 專用)
|
||||
//
|
||||
// 目的:
|
||||
// - 讓 axios interceptor 能用「事件」通知 UI 顯示錯誤提示,不需直接依賴 Pinia
|
||||
// - 預設只用於「非阻斷」錯誤(例如 500 / 網路中斷),避免導頁打斷使用者
|
||||
|
||||
export const HTTP_TOAST_EVENT = 'sk-playground:http-toast'
|
||||
|
||||
export type HttpToastLevel = 'info' | 'warning' | 'error'
|
||||
|
||||
export type HttpToastDetail = {
|
||||
level: HttpToastLevel
|
||||
message: string
|
||||
dedupeKey?: string
|
||||
}
|
||||
|
||||
let lastKey = ''
|
||||
let lastAt = 0
|
||||
|
||||
export function emitHttpToast (detail: HttpToastDetail) {
|
||||
const now = Date.now()
|
||||
const key = detail.dedupeKey ?? `${detail.level}:${detail.message}`
|
||||
|
||||
// 500ms 內同樣訊息不重複噴(避免同頁多支 API 一起爆)
|
||||
if (key === lastKey && now - lastAt < 500) return
|
||||
lastKey = key
|
||||
lastAt = now
|
||||
|
||||
window.dispatchEvent(new CustomEvent<HttpToastDetail>(HTTP_TOAST_EVENT, { detail }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { type AxiosError, AxiosHeaders, type AxiosInstance } from 'axios'
|
||||
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(userApi) -> 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')
|
||||
|
||||
if (token && shouldAttachToken) {
|
||||
const headers = AxiosHeaders.from(config.headers ?? {})
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
config.headers = headers
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response: 統一錯誤處理
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
const normalized = normalizeError(error)
|
||||
|
||||
// 取消請求不做全域錯誤導頁
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return Promise.reject(normalized)
|
||||
}
|
||||
|
||||
const requestUrl = error.config?.url ?? ''
|
||||
const isLoginRequest = requestUrl.includes('/Auth/login')
|
||||
const silentToast = Boolean(error.config?.meta?.silentToast)
|
||||
|
||||
// 統一處理 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'))
|
||||
if (hasAuthHeader) {
|
||||
tokenService.clearToken()
|
||||
const backendMessage = extractErrorMessage(error.response?.data)
|
||||
emitForceLogout({ message: backendMessage })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
// 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))
|
||||
|
||||
if (shouldToast) {
|
||||
emitHttpToast({
|
||||
level: status ? 'error' : 'warning',
|
||||
message: normalized.message
|
||||
})
|
||||
}
|
||||
return Promise.reject(normalized)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { CaptchaResponse } from '@/types/api'
|
||||
import { httpClient } from '../client'
|
||||
|
||||
export interface RequestOptions {
|
||||
// 供 AbortController 取消請求使用
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
getCaptcha: () => httpClient.get<CaptchaResponse>('/Auth/get-captcha'),
|
||||
login: (payload: FormData, options?: RequestOptions) =>
|
||||
httpClient.post<unknown>('/Auth/login', payload, {
|
||||
signal: options?.signal,
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { httpClient } from '../client'
|
||||
|
||||
export interface RequestOptions {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface MenuPayload {
|
||||
userID: string
|
||||
}
|
||||
|
||||
export interface MenuNode {
|
||||
children?: MenuNode[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MenuOuterResponse {
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export const menuApi = {
|
||||
getMenu: (payload: MenuPayload, options?: RequestOptions) =>
|
||||
httpClient.post<MenuOuterResponse>('/Menu/GetMenu', payload, {
|
||||
signal: options?.signal,
|
||||
}),
|
||||
getFavorite: (payload: MenuPayload, options?: RequestOptions) =>
|
||||
httpClient.post<MenuOuterResponse>('/Menu/GetFavorite', payload, {
|
||||
signal: options?.signal,
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 全域 Session 事件(Playground 專用)
|
||||
//
|
||||
// 目的:
|
||||
// - 避免 axios interceptor 直接 import Pinia store / router 造成循環依賴
|
||||
// - 由 App.vue 在 UI 層統一處理登出流程(清狀態、導頁、提示訊息)
|
||||
|
||||
export const SESSION_FORCE_LOGOUT_EVENT = 'sk-playground:session-force-logout'
|
||||
|
||||
type ForceLogoutDetail = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
let forceLogoutEmitted = false
|
||||
|
||||
export function emitForceLogout (detail: ForceLogoutDetail) {
|
||||
// 避免同一波大量 401 觸發多次登出流程
|
||||
if (forceLogoutEmitted) return
|
||||
forceLogoutEmitted = true
|
||||
|
||||
window.dispatchEvent(new CustomEvent(SESSION_FORCE_LOGOUT_EVENT, { detail }))
|
||||
|
||||
// 下一個 event loop 再允許觸發(避免把 guard 永久鎖住)
|
||||
setTimeout(() => {
|
||||
forceLogoutEmitted = false
|
||||
}, 0)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Token Service
|
||||
//
|
||||
// 設計重點:
|
||||
// - 單一來源(Single Source of Truth):用 ref 維護 token,並同步 localStorage
|
||||
// - Store 與 Interceptor 只透過此 service 讀寫 token,避免來源分裂
|
||||
|
||||
const storageKey = 'token'
|
||||
const tokenRef = ref<string | null>(localStorage.getItem(storageKey))
|
||||
|
||||
export const tokenService = {
|
||||
token: tokenRef,
|
||||
getToken() {
|
||||
return tokenRef.value
|
||||
},
|
||||
setToken(nextToken: string) {
|
||||
tokenRef.value = nextToken
|
||||
localStorage.setItem(storageKey, nextToken)
|
||||
},
|
||||
clearToken() {
|
||||
tokenRef.value = null
|
||||
localStorage.removeItem(storageKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user