Refactor MasterDetailMntC.vue for improved readability and consistency
This commit is contained in:
+20
-19
@@ -1,10 +1,10 @@
|
||||
元件 (Component)
|
||||
↓ 呼叫
|
||||
Store (Pinia) ← 管理狀態、快取
|
||||
↓ 呼叫
|
||||
API Service ← 封裝業務邏輯
|
||||
↓ 呼叫
|
||||
HTTP Client ← Axios 實例、攔截器
|
||||
↓ 呼叫
|
||||
Store (Pinia) ← 管理狀態、快取
|
||||
↓ 呼叫
|
||||
API Service ← 封裝業務邏輯
|
||||
↓ 呼叫
|
||||
HTTP Client ← Axios 實例、攔截器
|
||||
|
||||
## 目前的資料流(以登入為例)
|
||||
|
||||
@@ -27,12 +27,12 @@ HTTP Client ← Axios 實例、攔截器
|
||||
|
||||
```ts
|
||||
interface MenuNode {
|
||||
mdl_id: string // 模組 ID
|
||||
mdl_name: string // 模組名稱
|
||||
unt_id?: string // 單位 ID
|
||||
unt_name?: string // 單位名稱
|
||||
fnc_id?: string // 功能 ID
|
||||
fnc_name?: string // 功能名稱
|
||||
mdl_id: string // 模組 ID
|
||||
mdl_name: string // 模組名稱
|
||||
unt_id?: string // 單位 ID
|
||||
unt_name?: string // 單位名稱
|
||||
fnc_id?: string // 功能 ID
|
||||
fnc_name?: string // 功能名稱
|
||||
children?: MenuNode[]
|
||||
}
|
||||
```
|
||||
@@ -46,6 +46,7 @@ interface MenuNode {
|
||||
### Store 持久化
|
||||
|
||||
`stores/menu.ts` 提供:
|
||||
|
||||
- 自動 localStorage 持久化選單與收藏
|
||||
- 初始化時自動還原資料
|
||||
- 登出時清除快取
|
||||
@@ -105,10 +106,10 @@ Store 仍然是「唯一負責更新 token 的地方」,Interceptor 只負責
|
||||
- `normalizeError` 會將取消行為轉為 `CanceledRequestError`
|
||||
- UI 不顯示取消造成的錯誤訊息
|
||||
|
||||
| DECISION | WHY | WHY NOT |
|
||||
|---|---|---|
|
||||
| Store 呼叫 API,不是元件直接呼叫 | 狀態集中管理、可快取、易測試 | 元件直接呼叫會導致狀態散落 |
|
||||
| API 模組化(userApi、orderApi)| 關注點分離、好維護 | 全塞一個檔案會變超大 |
|
||||
| Interceptor 獨立檔案| 單一職責、好測試 | 寫在 client.ts 會雜亂 |
|
||||
| 泛型 ApiResponse<T>| 型別安全、IDE 提示 | 用 any 會失去 TS 優勢 |
|
||||
| API 前綴使用 `/api` | 使用習慣一致、搭配 Vite proxy 容易理解 | 若前端資料夾也叫 `api` 容易造成路徑衝突,因此此專案改名為 `services` |
|
||||
| DECISION | WHY | WHY NOT |
|
||||
| -------------------------------- | -------------------------------------- | -------------------------------------------------------------------- |
|
||||
| Store 呼叫 API,不是元件直接呼叫 | 狀態集中管理、可快取、易測試 | 元件直接呼叫會導致狀態散落 |
|
||||
| API 模組化(userApi、orderApi) | 關注點分離、好維護 | 全塞一個檔案會變超大 |
|
||||
| Interceptor 獨立檔案 | 單一職責、好測試 | 寫在 client.ts 會雜亂 |
|
||||
| 泛型 ApiResponse<T> | 型別安全、IDE 提示 | 用 any 會失去 TS 優勢 |
|
||||
| API 前綴使用 `/api` | 使用習慣一致、搭配 Vite proxy 容易理解 | 若前端資料夾也叫 `api` 容易造成路徑衝突,因此此專案改名為 `services` |
|
||||
|
||||
@@ -7,7 +7,7 @@ import { setupInterceptors } from './interceptors'
|
||||
// - 透過單一 axios instance 統一管理 baseURL、timeout、headers 與攔截器
|
||||
// - 預設 baseURL 使用 `/api`:搭配 Vite proxy 轉送到後端 dev server
|
||||
// - 攔截器集中在 `interceptors.ts`,避免 client.ts 變得難維護
|
||||
function createClient (): AxiosInstance {
|
||||
function createClient(): AxiosInstance {
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '/service/api'
|
||||
const client = axios.create({
|
||||
baseURL,
|
||||
@@ -19,4 +19,3 @@ function createClient (): AxiosInstance {
|
||||
}
|
||||
|
||||
export const httpClient = createClient()
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ApiError } from '@/types/api'
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
function isRecord (value: unknown): value is Record<string, unknown> {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function firstString (value: unknown): string | undefined {
|
||||
function firstString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
@@ -47,7 +47,7 @@ function firstString (value: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function extractErrorMessage (data: unknown): string | undefined {
|
||||
export function extractErrorMessage(data: unknown): string | undefined {
|
||||
return firstString(data)
|
||||
}
|
||||
|
||||
@@ -86,14 +86,14 @@ export class CanceledRequestError extends ApiRequestError {
|
||||
}
|
||||
}
|
||||
|
||||
export function isRequestCanceled (error: unknown): boolean {
|
||||
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 {
|
||||
export function normalizeError(error: unknown): ApiRequestError {
|
||||
if (error instanceof ApiRequestError) {
|
||||
return error
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export function normalizeError (error: unknown): ApiRequestError {
|
||||
code,
|
||||
status,
|
||||
errors: apiError?.errors,
|
||||
raw: error
|
||||
raw: error,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export type HttpErrorDetail = {
|
||||
|
||||
let httpErrorEmitted = false
|
||||
|
||||
export function emitHttpError (detail: HttpErrorDetail) {
|
||||
export function emitHttpError(detail: HttpErrorDetail) {
|
||||
// 避免同一波大量錯誤觸發多次導頁
|
||||
if (httpErrorEmitted) return
|
||||
httpErrorEmitted = true
|
||||
@@ -25,4 +25,3 @@ export function emitHttpError (detail: HttpErrorDetail) {
|
||||
httpErrorEmitted = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export type HttpToastDetail = {
|
||||
let lastKey = ''
|
||||
let lastAt = 0
|
||||
|
||||
export function emitHttpToast (detail: HttpToastDetail) {
|
||||
export function emitHttpToast(detail: HttpToastDetail) {
|
||||
const now = Date.now()
|
||||
const key = detail.dedupeKey ?? `${detail.level}:${detail.message}`
|
||||
|
||||
@@ -28,4 +28,3 @@ export function emitHttpToast (detail: HttpToastDetail) {
|
||||
|
||||
window.dispatchEvent(new CustomEvent<HttpToastDetail>(HTTP_TOAST_EVENT, { detail }))
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { tokenService } from './token'
|
||||
// 注意:
|
||||
// - Store 仍然是唯一負責「寫入/清除 token」的地方(login/logout)
|
||||
// - Interceptor 只負責「讀取 token 並附加到 request」
|
||||
export function setupInterceptors (client: AxiosInstance) {
|
||||
export function setupInterceptors(client: AxiosInstance) {
|
||||
// Request: 自動注入 token
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
@@ -97,8 +97,8 @@ export function setupInterceptors (client: AxiosInstance) {
|
||||
break
|
||||
}
|
||||
default:
|
||||
// 無 response status 時,多半是網路/跨網域/連線問題:
|
||||
// 交由呼叫端決定 UI(snackbar/區塊錯誤/重試),避免全域導頁打斷使用者操作
|
||||
// 無 response status 時,多半是網路/跨網域/連線問題:
|
||||
// 交由呼叫端決定 UI(snackbar/區塊錯誤/重試),避免全域導頁打斷使用者操作
|
||||
}
|
||||
|
||||
const shouldToast =
|
||||
@@ -113,7 +113,7 @@ export function setupInterceptors (client: AxiosInstance) {
|
||||
if (shouldToast) {
|
||||
emitHttpToast({
|
||||
level: status ? 'error' : 'warning',
|
||||
message: normalized.message
|
||||
message: normalized.message,
|
||||
})
|
||||
}
|
||||
return Promise.reject(normalized)
|
||||
|
||||
@@ -12,7 +12,7 @@ type ForceLogoutDetail = {
|
||||
|
||||
let forceLogoutEmitted = false
|
||||
|
||||
export function emitForceLogout (detail: ForceLogoutDetail) {
|
||||
export function emitForceLogout(detail: ForceLogoutDetail) {
|
||||
// 避免同一波大量 401 觸發多次登出流程
|
||||
if (forceLogoutEmitted) return
|
||||
forceLogoutEmitted = true
|
||||
|
||||
@@ -21,5 +21,5 @@ export const tokenService = {
|
||||
clearToken() {
|
||||
tokenRef.value = null
|
||||
localStorage.removeItem(storageKey)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user