Refactor MasterDetailMntC.vue for improved readability and consistency

This commit is contained in:
skytek_xinliang
2026-03-30 09:18:55 +08:00
parent 7591ecd062
commit 16b58fbf7a
66 changed files with 2071 additions and 777 deletions
+20 -19
View File
@@ -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` |
+1 -2
View File
@@ -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()
+6 -6
View File
@@ -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,
})
}
+1 -2
View File
@@ -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)
}
+1 -2
View File
@@ -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 }))
}
+4 -4
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -21,5 +21,5 @@ export const tokenService = {
clearToken() {
tokenRef.value = null
localStorage.removeItem(storageKey)
}
},
}