fix: abort機制

This commit is contained in:
skytek_xinliang
2026-06-01 14:50:47 +08:00
parent f61432ad8a
commit 915f3b7f2f
9 changed files with 70 additions and 16 deletions
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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`(維持) |
+1 -1
View File
@@ -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`
+1
View File
@@ -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。
+6
View File
@@ -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。
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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')
} }
} }
+33
View File
@@ -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,
}
}