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