fix: abort機制
This commit is contained in:
@@ -4,11 +4,11 @@ registry=https://registry.npmjs.org/
|
||||
# 自動儲存精確版本號 (不帶 ^ 或 ~),避免版本漂移
|
||||
# save-exact=true
|
||||
|
||||
# 安全防禦:禁止安裝發布未滿 7 天的套件 (預防供應鏈攻擊)
|
||||
# 安全防禦:禁止安裝發布未滿 4 天的套件 (預防供應鏈攻擊)
|
||||
# npm v11.10+
|
||||
min-release-age=7
|
||||
min-release-age=4
|
||||
# pnpm
|
||||
minimum-release-age=10080
|
||||
minimum-release-age=5760
|
||||
|
||||
# 嚴格版本檢查:若 Node 或 pnpm 版本不符 package.json 定義則報錯
|
||||
# engine-strict=true
|
||||
|
||||
@@ -183,7 +183,7 @@ src/
|
||||
```vue
|
||||
<!-- views/maint/SingleRecord.vue(優化後) -->
|
||||
<script setup lang="ts">
|
||||
import MaintShell from '@/components/MaintShell.vue'
|
||||
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
||||
|
||||
const { commands, formPanelEvents, formPanelProps, pageModel, searchPanelOpen } = useSingleRecordMaintenancePage()
|
||||
@@ -203,7 +203,7 @@ const { commands, formPanelEvents, formPanelProps, pageModel, searchPanelOpen }
|
||||
```vue
|
||||
<!-- views/maint/SingleRecord.vue -->
|
||||
<script setup lang="ts">
|
||||
import MaintShell from '@/components/MaintShell.vue'
|
||||
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
||||
|
||||
const { pageModel, searchPanelOpen, commands, search, students, ... } = useSingleRecordMaintenancePage()
|
||||
@@ -347,7 +347,7 @@ views/xxx.vue
|
||||
- View 中不再直接定義 `<teleport>`、`<v-overlay>` 或多個確認 dialog。
|
||||
5. [x] 將表單欄位抽出到 `src/components/items/ItemFormFieldGroup.vue`。
|
||||
- 只呈現欄位與欄位錯誤,透過 `v-model` 與 `clear-field-error` 與上層互動。
|
||||
6. [x] 將 CRUD command 流程抽出到 `src/composables/commands/useCrudCommands.ts`。
|
||||
6. [x] 將 CRUD command 流程抽出到 `src/composables/useCrudCommands.ts`。
|
||||
- 負責新增、載入編輯/檢視資料、儲存前確認、實際儲存與 highlight 流程。
|
||||
7. [x] 新增 `src/composables/page-drivers/useSingleRecordMaintenancePage.ts` 作為本頁協調層。
|
||||
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
|
||||
@@ -402,7 +402,7 @@ views/xxx.vue
|
||||
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
|
||||
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
|
||||
| Page Driver Composable | `src/composables/page-drivers/` | `useSingleRecordMaintenancePage.ts` |
|
||||
| Command Composable | `src/composables/commands/` | `useCrudCommands.ts` |
|
||||
| Command Composable | `src/composables/` | `useCrudCommands.ts` |
|
||||
| Form Composable | `src/composables/forms/` | `useForm.ts` |
|
||||
| Domain Store | `src/stores/` | `students.ts`(維持) |
|
||||
| Service Module | `src/services/modules/` | `students.ts`(維持) |
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- `src/components/sections/*`
|
||||
- `src/components/items/*`
|
||||
- `src/composables/page-drivers/*`
|
||||
- `src/composables/commands/*`
|
||||
- `src/composables/useCrudCommands.ts`
|
||||
- `src/stores/*`
|
||||
- `src/services/modules/*`
|
||||
- `src/router/routes.ts`
|
||||
|
||||
@@ -32,3 +32,4 @@ service module 不需要自行 catch 並處理錯誤,交由 interceptors/hooks
|
||||
- JSON payload 用 `json`,FormData 用 `body`。
|
||||
- 取消請求使用原生 `AbortController` 與 `signal`。
|
||||
- token 注入與 401 force logout 集中在 hooks,不在單一 API module 重寫。
|
||||
- 重複請求取消策略(key 命名、何時 abort、何時清理)由 store/composable 決定,service module 不應持有 controller map。
|
||||
|
||||
@@ -84,3 +84,9 @@ token 由 `tokenService` 作為單一來源:
|
||||
## 請求取消
|
||||
|
||||
需要取消請求時,由 store 或 composable 建立 `AbortController`,service module 只接收 `signal`。不要讓 service module 持有 controller 或 UI 狀態。
|
||||
|
||||
建議做法:
|
||||
|
||||
- 在 store/composable 以 key 管理同類請求(例如 `auth/login`、`menu/get-menu`)。
|
||||
- 發新請求前先取消同 key 舊請求,避免競態與多餘流量。
|
||||
- 請求結束後於 `finally` 清理該 key;離開流程(如 `clear`、`logout`)時清理全部 key。
|
||||
|
||||
@@ -21,3 +21,10 @@
|
||||
## 資料流
|
||||
|
||||
store 可以呼叫 service module。component 不應繞過 store/composable 直接處理 token、session 或 HTTP hooks。
|
||||
|
||||
## 請求取消慣例
|
||||
|
||||
- 需要避免重複提交或快速切換造成的舊請求殘留時,在 store 層管理 `AbortController`。
|
||||
- 同一類請求使用固定 key(例如 `auth/login`、`menu/get-menu`),新請求前先取消舊請求。
|
||||
- service module 只接收 `signal`,不管理 controller lifecycle。
|
||||
- store 在 `finally` 清理該 key,在 `clear/logout` 清理全部 key。
|
||||
|
||||
+6
-6
@@ -5,6 +5,7 @@ import { normalizeError } from '@/services/error'
|
||||
import { authApi } from '@/services/modules/auth'
|
||||
import { tokenService } from '@/services/token'
|
||||
import { useMenuStore } from '@/stores/menu'
|
||||
import { createRequestControllerManager } from '@/stores/request-controller'
|
||||
|
||||
const defaultLoginRequestFormat: LoginRequestFormat = 'formData'
|
||||
|
||||
@@ -56,22 +57,20 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const token = tokenService.token
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
// 只針對 login 取消重複請求,避免競態與重複提交
|
||||
const loginController = ref<AbortController | null>(null)
|
||||
const requestControllerManager = createRequestControllerManager()
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
|
||||
|
||||
const login = async (payload: LoginPayload, options: LoginOptions = {}) => {
|
||||
loginController.value?.abort()
|
||||
loginController.value = new AbortController()
|
||||
const signal = requestControllerManager.replace('auth/login')
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const requestBody = createLoginRequestBody(payload)
|
||||
const requestFormat = options.requestFormat ?? defaultLoginRequestFormat
|
||||
const requestOptions = { signal: loginController.value.signal }
|
||||
const requestOptions = { signal }
|
||||
const { data } =
|
||||
requestFormat === 'json'
|
||||
? await authApi.loginWithJson(requestBody, requestOptions)
|
||||
@@ -125,11 +124,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
loginController.value = null
|
||||
requestControllerManager.clear('auth/login')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
requestControllerManager.clearAll()
|
||||
user.value = null
|
||||
tokenService.clearToken()
|
||||
useMenuStore().clear()
|
||||
|
||||
+9
-2
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeError } from '@/services/error'
|
||||
import { menuApi, type MenuNode } from '@/services/modules/menu'
|
||||
import { createRequestControllerManager } from '@/stores/request-controller'
|
||||
|
||||
export interface LayoutMenuItem {
|
||||
title: string
|
||||
@@ -17,6 +18,7 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
const isRail = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const requestControllerManager = createRequestControllerManager()
|
||||
|
||||
const menuStorageKey = 'sk_playground_menu'
|
||||
const favoriteStorageKey = 'sk_playground_favorite'
|
||||
@@ -180,6 +182,7 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
requestControllerManager.clearAll()
|
||||
menu.value = []
|
||||
favorite.value = []
|
||||
isRail.value = false
|
||||
@@ -190,9 +193,10 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
}
|
||||
|
||||
const getMenu = async (id: string) => {
|
||||
const signal = requestControllerManager.replace('menu/get-menu')
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await menuApi.getMenu({ userID: id })
|
||||
const res = await menuApi.getMenu({ userID: id }, { signal })
|
||||
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
@@ -202,13 +206,15 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
requestControllerManager.clear('menu/get-menu')
|
||||
}
|
||||
}
|
||||
|
||||
const getFavorite = async (id: string) => {
|
||||
const signal = requestControllerManager.replace('menu/get-favorite')
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await menuApi.getFavorite({ userID: id })
|
||||
const res = await menuApi.getFavorite({ userID: id }, { signal })
|
||||
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
@@ -218,6 +224,7 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
requestControllerManager.clear('menu/get-favorite')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface RequestControllerManager {
|
||||
replace: (key: string) => AbortSignal
|
||||
clear: (key: string) => void
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
export function createRequestControllerManager(): RequestControllerManager {
|
||||
const controllers = new Map<string, AbortController>()
|
||||
|
||||
const replace = (key: string): AbortSignal => {
|
||||
controllers.get(key)?.abort()
|
||||
const controller = new AbortController()
|
||||
controllers.set(key, controller)
|
||||
return controller.signal
|
||||
}
|
||||
|
||||
const clear = (key: string) => {
|
||||
controllers.delete(key)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
controllers.forEach((controller) => {
|
||||
controller.abort()
|
||||
})
|
||||
controllers.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
replace,
|
||||
clear,
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user