diff --git a/docs/add-page-example.md b/docs/add-page-example.md index 45e4238..d8bc437 100644 --- a/docs/add-page-example.md +++ b/docs/add-page-example.md @@ -99,7 +99,9 @@ export interface ReportSummary { } export const reportsApi = { - list: () => httpClient.get('/Reports'), + list: async () => ({ + data: await httpClient.get('Reports').json(), + }), } ``` diff --git a/docs/llm-development-guide.md b/docs/llm-development-guide.md index 92c578d..e5fc73b 100644 --- a/docs/llm-development-guide.md +++ b/docs/llm-development-guide.md @@ -241,7 +241,7 @@ route 集中放在 `src/router/routes.ts`。不要在 view 或 component 裡臨 store 檔案直接放在 `src/stores/*.ts`。不要建立 `src/stores/stores/*` 或其他重複巢狀 store 目錄。 -service 放在 `src/services`,負責外部 API 與 HTTP 細節。component 不應直接處理底層 HTTP client、token、interceptor 或錯誤正規化。 +service 放在 `src/services`,負責外部 API 與 HTTP 細節。component 不應直接處理底層 HTTP client、token、hooks 或錯誤正規化。 資料流優先順序: @@ -251,7 +251,7 @@ service 放在 `src/services`,負責外部 API 與 HTTP 細節。component 不 4. store 或 composable 呼叫 service。 5. service 回傳資料,不持有 UI 狀態。 -不要讓 service import component、view 或 store。不要讓 component 直接繞過既有 store/composable 去操作 token、session 或 HTTP interceptor。 +不要讓 service import component、view 或 store。不要讓 component 直接繞過既有 store/composable 去操作 token、session 或 HTTP hooks。 ## 環境變數規則 diff --git a/env.d.ts b/env.d.ts index 6b471d9..5cfd1ab 100644 --- a/env.d.ts +++ b/env.d.ts @@ -1,6 +1,5 @@ /// -import 'axios' import 'vue-router' declare module 'vue-router' { @@ -13,17 +12,3 @@ declare module 'vue-router' { } } -declare module 'axios' { - interface AxiosRequestConfig { - meta?: { - silentToast?: boolean - } - } - - interface InternalAxiosRequestConfig { - meta?: { - silentToast?: boolean - } - } -} - diff --git a/package.json b/package.json index 1c3e427..9902490 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@mdi/js": "^7.4.47", - "axios": "^1.13.6", + "ky": "^2.0.2", "pinia": "^3.0.4", "vue": "^3.5.31", "vue-i18n": "^11.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cd763e..3f028d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,9 @@ importers: '@mdi/js': specifier: ^7.4.47 version: 7.4.47 - axios: - specifier: ^1.13.6 - version: 1.13.6 + ky: + specifier: ^2.0.2 + version: 2.0.2 pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3)) @@ -487,23 +487,13 @@ packages: resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} engines: {node: '>=20.19.0'} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axe-core@4.11.1: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} - axios@1.13.6: - resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} - birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -519,10 +509,6 @@ packages: colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -549,18 +535,10 @@ packages: supports-color: optional: true - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -568,22 +546,6 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -603,19 +565,6 @@ packages: picomatch: optional: true - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -626,9 +575,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -637,34 +583,10 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -708,6 +630,10 @@ packages: engines: {node: '>=6'} hasBin: true + ky@2.0.2: + resolution: {integrity: sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==} + engines: {node: '>=22'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -793,22 +719,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - memorystream@0.3.1: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -900,9 +814,6 @@ packages: engines: {node: '>=14'} hasBin: true - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -1658,25 +1569,10 @@ snapshots: '@babel/parser': 7.29.2 ast-kit: 2.2.0 - asynckit@0.4.0: {} - axe-core@4.11.1: {} - axios@1.13.6: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - birpc@2.9.0: {} - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -1694,10 +1590,6 @@ snapshots: colorjs.io@0.5.2: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - confbox@0.1.8: {} confbox@0.2.4: {} @@ -1718,35 +1610,12 @@ snapshots: dependencies: ms: 2.1.3 - delayed-stream@1.0.0: {} - detect-libc@2.1.2: {} - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - emoji-regex@10.6.0: {} entities@7.0.1: {} - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - escalade@3.2.0: {} estree-walker@2.0.2: {} @@ -1757,60 +1626,18 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - follow-redirects@1.15.11: {} - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - fsevents@2.3.2: optional: true fsevents@2.3.3: optional: true - function-bind@1.1.2: {} - get-caller-file@2.0.5: {} get-east-asian-width@1.5.0: {} - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - gopd@1.2.0: {} - has-flag@4.0.0: {} - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - hookable@5.5.3: {} immutable@5.1.5: {} @@ -1839,6 +1666,8 @@ snapshots: json5@2.2.3: {} + ky@2.0.2: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -1902,16 +1731,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - math-intrinsics@1.1.0: {} - memorystream@0.3.1: {} - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mitt@3.0.1: {} mlly@1.8.2: @@ -1994,8 +1815,6 @@ snapshots: prettier@3.8.1: {} - proxy-from-env@1.1.0: {} - quansync@0.2.11: {} read-package-json-fast@4.0.0: diff --git a/src/services/README.md b/src/services/README.md index 43f6c0e..bc23f64 100644 --- a/src/services/README.md +++ b/src/services/README.md @@ -1,24 +1,24 @@ # Services -`src/services` 是資料存取與 HTTP 邊界,負責封裝 axios client、interceptor、token/session、錯誤處理與 API 模組。 +`src/services` 是資料存取與 HTTP 邊界,負責封裝 ky client、hooks、token/session、錯誤處理與 API 模組。 ## 目前資料流 ```txt -component/view -> store/composable -> service module -> httpClient -> interceptor +component/view -> store/composable -> service module -> httpClient -> hooks ``` 原則: -- component 不直接處理底層 HTTP client、token、interceptor 或錯誤正規化。 +- component 不直接處理底層 HTTP client、token、hooks 或錯誤正規化。 - store 或 composable 負責協調 UI 狀態與呼叫 service。 - service 回傳資料,不持有 UI 狀態。 - service 不 import component、view 或 store。 ## 目前檔案 -- `client.ts`:建立單一 axios instance,設定 `baseURL`、timeout、credentials 與 interceptor。 -- `interceptors.ts`:集中處理 request token 注入與 response 錯誤。 +- `client.ts`:建立單一 ky instance,設定 `prefix`、timeout、credentials 與 hooks。 +- `interceptors.ts`:集中提供 ky hooks,處理 request token 注入與 response 錯誤。 - `error.ts`:提供 `normalizeError()` 與統一錯誤型別。 - `http-error.ts`:提供全域 HTTP 錯誤事件。 - `http-toast.ts`:提供 HTTP 錯誤提示相關流程。 @@ -38,6 +38,18 @@ API module 應: - 定義與該 module 相關的 request/response 型別。 - 接收 `AbortSignal` 等 request option,但不管理頁面 loading 或 controller 狀態。 +## ky 使用注意事項 + +本專案使用 ky,不使用 axios。新增或調整 API module 時注意: + +- ky 不回傳 axios 的 `{ data, status, headers }` 物件。需要 JSON 時使用 `.json()`。 +- 若呼叫端已經依賴 `{ data }` 形狀,請在 API module 內包回 `{ data: await ... }`,不要讓 store 或 component 混用多種 response 形狀。 +- ky 的錯誤型別是 `HTTPError`、`TimeoutError` 等,不是 `AxiosError`。錯誤一律交給 `normalizeError()`,呼叫端不要直接判斷 ky error。 +- ky 基於 Fetch API,取消請求使用原生 `AbortController` 與 `signal`。 +- token 注入、401 force logout、HTTP 錯誤導頁與 toast 都集中在 ky hooks。不要在單一 service module 裡重複實作。 +- FormData 請用 `body: formData`;JSON payload 請用 `json: payload`。 +- 如果需求需要 upload progress、request/response transform、或其他 axios 專屬行為,先確認 ky/fetch 是否有等價做法,再決定是否擴充 service layer。 + ## HTTP Client 設定 `client.ts` 的 `baseURL` 優先使用 `VITE_API_BASE_URL`,否則使用 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。 @@ -64,10 +76,10 @@ production 不應沿用 template 內的示範後端位址,應由使用專案 token 由 `tokenService` 作為單一來源: - store 負責登入成功後寫入 token,以及登出時清除 token。 -- interceptor 只讀取 token 並附加到 request。 -- 401 或 HTTP 錯誤由 interceptor 與錯誤事件流程集中處理。 +- hooks 只讀取 token 並附加到 request。 +- 401 或 HTTP 錯誤由 hooks 與錯誤事件流程集中處理。 -錯誤透過 `normalizeError()` 轉成 UI 可理解的格式。UI 或 store 不需要直接理解 AxiosError。 +錯誤透過 `normalizeError()` 轉成 UI 可理解的格式。UI 或 store 不需要直接理解 ky 的 HTTPError。 ## 請求取消 diff --git a/src/services/client.ts b/src/services/client.ts index 792595f..c4ee1df 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -1,21 +1,14 @@ -import axios, { type AxiosInstance } from 'axios' -import { setupInterceptors } from './interceptors' +import ky, { type KyInstance } from 'ky' +import { createHooks } from './interceptors' -// HTTP Client(Axios instance) -// -// 設計重點: -// - 透過單一 axios instance 統一管理 baseURL、timeout、headers 與攔截器 -// - 預設 baseURL 使用 `/api`:搭配 Vite proxy 轉送到後端 dev server -// - 攔截器集中在 `interceptors.ts`,避免 client.ts 變得難維護 -function createClient(): AxiosInstance { +function createClient(): KyInstance { const baseURL = import.meta.env.VITE_API_BASE_URL || '/service/api' - const client = axios.create({ - baseURL, + return ky.create({ + prefix: baseURL, timeout: 10_000, - withCredentials: true, + credentials: 'include', + hooks: createHooks(), }) - setupInterceptors(client) - return client } export const httpClient = createClient() diff --git a/src/services/error.ts b/src/services/error.ts index 5c5724a..47c85a2 100644 --- a/src/services/error.ts +++ b/src/services/error.ts @@ -1,5 +1,5 @@ import type { ApiError } from '@/types/api' -import { isAxiosError } from 'axios' +import { isHTTPError, isTimeoutError } from 'ky' function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -51,12 +51,6 @@ export function extractErrorMessage(data: unknown): string | undefined { return firstString(data) } -// 統一錯誤格式 -// -// 設計重點: -// - 將 AxiosError 與非預期錯誤統一轉成 ApiRequestError -// - Store 只需要處理 message/code/status,不需理解 Axios 結構 -// - 取消請求(AbortController)會轉成 CanceledRequestError export class ApiRequestError extends Error { code?: number status?: number @@ -87,9 +81,6 @@ export class CanceledRequestError extends ApiRequestError { } export function isRequestCanceled(error: unknown): boolean { - if (isAxiosError(error)) { - return error.code === 'ERR_CANCELED' - } return error instanceof DOMException && error.name === 'AbortError' } @@ -102,9 +93,9 @@ export function normalizeError(error: unknown): ApiRequestError { return new CanceledRequestError() } - if (isAxiosError(error)) { - const status = error.response?.status - const data = error.response?.data as unknown + if (isHTTPError(error)) { + const status = error.response.status + const data = error.data const message = extractErrorMessage(data) || error.message || '請求失敗' const apiError = isRecord(data) ? (data as Partial) : undefined const code = apiError?.code ?? status @@ -117,6 +108,13 @@ export function normalizeError(error: unknown): ApiRequestError { }) } + if (isTimeoutError(error)) { + return new ApiRequestError({ + message: '請求逾時', + raw: error, + }) + } + if (error instanceof Error) { return new ApiRequestError({ message: error.message, raw: error }) } diff --git a/src/services/http-error.ts b/src/services/http-error.ts index 0d6fe1d..eac1f9c 100644 --- a/src/services/http-error.ts +++ b/src/services/http-error.ts @@ -1,7 +1,7 @@ // 全域 HTTP 錯誤事件(Playground 專用) // // 目的: -// - 避免 axios interceptor 直接 import router 造成耦合 +// - 避免 HTTP hooks 直接 import router 造成耦合 // - 由 router 層或 App 層決定要導到哪個錯誤頁與顯示哪些訊息 export const HTTP_ERROR_EVENT = 'sk-playground:http-error' diff --git a/src/services/http-toast.ts b/src/services/http-toast.ts index 3595db8..30670af 100644 --- a/src/services/http-toast.ts +++ b/src/services/http-toast.ts @@ -1,7 +1,7 @@ // 全域 HTTP Toast 事件(Playground 專用) // // 目的: -// - 讓 axios interceptor 能用「事件」通知 UI 顯示錯誤提示,不需直接依賴 Pinia +// - 讓 HTTP hooks 能用「事件」通知 UI 顯示錯誤提示,不需直接依賴 Pinia // - 預設只用於「非阻斷」錯誤(例如 500 / 網路中斷),避免導頁打斷使用者 export const HTTP_TOAST_EVENT = 'sk-playground:http-toast' diff --git a/src/services/interceptors.ts b/src/services/interceptors.ts index 20c4bdf..b61161d 100644 --- a/src/services/interceptors.ts +++ b/src/services/interceptors.ts @@ -1,122 +1,85 @@ -import { type AxiosError, AxiosHeaders, type AxiosInstance } from 'axios' +import type { Hooks } from 'ky' +import { isHTTPError } from 'ky' import { extractErrorMessage, normalizeError } from './error' import { emitHttpError } from './http-error' import { emitHttpToast } from './http-toast' import { emitForceLogout } from './session' import { tokenService } from './token' -// Axios 攔截器 -// -// 設計重點: -// - Request:自動注入 token(從 localStorage 讀取) -// - 使用 tokenService 作為單一來源,避免 interceptor 直接 import Pinia store 造成循環依賴 -// store(auth) -> services(authApi/menuApi) -> httpClient -> interceptors -> store(auth) -// - Response:統一處理 HTTP 錯誤(目前示範 401/403/500) -// - 使用 normalizeError 將錯誤轉成 ApiRequestError -// -// 注意: -// - Store 仍然是唯一負責「寫入/清除 token」的地方(login/logout) -// - Interceptor 只負責「讀取 token 並附加到 request」 -export function setupInterceptors(client: AxiosInstance) { - // Request: 自動注入 token - client.interceptors.request.use( - (config) => { - const token = tokenService.getToken() - const url = config.url ?? '' - const shouldAttachToken = !url.includes('/Auth/login') +export function createHooks(): Hooks { + return { + beforeRequest: [ + ({ request }) => { + const token = tokenService.getToken() + const url = request.url + const shouldAttachToken = !url.includes('/Auth/login') - if (token && shouldAttachToken) { - const headers = AxiosHeaders.from(config.headers ?? {}) - headers.set('Authorization', `Bearer ${token}`) - config.headers = headers - } - return config - }, - (error) => { - return Promise.reject(error) - } - ) + if (token && shouldAttachToken) { + request.headers.set('Authorization', `Bearer ${token}`) + } + }, + ], + beforeError: [ + ({ request, options, error }) => { + const normalized = normalizeError(error) - // Response: 統一錯誤處理 - client.interceptors.response.use( - (response) => response, - (error: AxiosError) => { - const normalized = normalizeError(error) + if (normalized.name === 'CanceledRequestError') { + return normalized + } - // 取消請求不做全域錯誤導頁 - if (error.code === 'ERR_CANCELED') { - return Promise.reject(normalized) - } + const requestUrl = request.url + const isLoginRequest = requestUrl.includes('/Auth/login') + const silentToast = Boolean(options.context.silentToast) + const status = isHTTPError(error) ? error.response.status : undefined - const requestUrl = error.config?.url ?? '' - const isLoginRequest = requestUrl.includes('/Auth/login') - const silentToast = Boolean(error.config?.meta?.silentToast) + switch (status) { + case 401: { + if (requestUrl.includes('/Auth/login')) break - // 統一處理 HTTP 狀態碼錯誤 - const status = error.response?.status - switch (status) { - // 401 Unauthorized - case 401: { - // 不是所有 401 都代表「token 過期」: - // - 登入失敗通常也會是 401,但不應觸發全域登出流程 - // 這裡以「是否帶 Authorization header」作為判斷依據 - { - const url = error.config?.url ?? '' - if (url.includes('/Auth/login')) break - - const requestHeaders = AxiosHeaders.from(error.config?.headers ?? {}) - const hasAuthHeader = Boolean(requestHeaders.get('Authorization')) + const hasAuthHeader = request.headers.has('Authorization') if (hasAuthHeader) { tokenService.clearToken() - const backendMessage = extractErrorMessage(error.response?.data) + const backendMessage = isHTTPError(error) ? extractErrorMessage(error.data) : undefined emitForceLogout({ message: backendMessage }) } + break } - break + case 403: { + emitHttpError({ status, message: normalized.message }) + break + } + case 404: { + emitHttpError({ status, message: normalized.message }) + break + } + case 500: { + break + } + case 503: { + emitHttpError({ status, message: normalized.message }) + break + } + default: } - // 403 Forbidden - case 403: { - emitHttpError({ status, message: normalized.message }) - break - } - // 404 Not Found(API 端點不存在/被移除) - case 404: { - emitHttpError({ status, message: normalized.message }) - break - } - // 500 Internal Server Error - case 500: { - // 500 通常是「單一 API 失敗」:交由呼叫端決定 UI(snackbar/區塊錯誤/重試) - // 避免同一頁多支 API 時,其中一支 500 就把整個頁面導走 - break - } - // 503 Service Unavailable - case 503: { - // 503 通常對使用者來說就是「系統維護/暫時無法使用」 - emitHttpError({ status, message: normalized.message }) - break - } - default: - // 無 response status 時,多半是網路/跨網域/連線問題: - // 交由呼叫端決定 UI(snackbar/區塊錯誤/重試),避免全域導頁打斷使用者操作 - } - const shouldToast = - !silentToast && - !isLoginRequest && - status !== 401 && - status !== 403 && - status !== 404 && - status !== 503 && - (status === 500 || !status || (typeof status === 'number' && status >= 500)) + const shouldToast = + !silentToast && + !isLoginRequest && + status !== 401 && + status !== 403 && + status !== 404 && + status !== 503 && + (status === 500 || !status || (typeof status === 'number' && status >= 500)) - if (shouldToast) { - emitHttpToast({ - level: status ? 'error' : 'warning', - message: normalized.message, - }) - } - return Promise.reject(normalized) - } - ) + if (shouldToast) { + emitHttpToast({ + level: status ? 'error' : 'warning', + message: normalized.message, + }) + } + + return normalized + }, + ], + } } diff --git a/src/services/modules/auth.ts b/src/services/modules/auth.ts index 74d707e..00ee9b5 100644 --- a/src/services/modules/auth.ts +++ b/src/services/modules/auth.ts @@ -7,9 +7,15 @@ export interface RequestOptions { } export const authApi = { - getCaptcha: () => httpClient.get('/Auth/get-captcha'), - login: (payload: FormData, options?: RequestOptions) => - httpClient.post('/Auth/login', payload, { + getCaptcha: async () => ({ + data: await httpClient.get('Auth/get-captcha').json(), + }), + login: async (payload: FormData, options?: RequestOptions) => ({ + data: await httpClient + .post('Auth/login', { + body: payload, signal: options?.signal, - }), + }) + .json(), + }), } diff --git a/src/services/modules/menu.ts b/src/services/modules/menu.ts index a228804..d547d93 100644 --- a/src/services/modules/menu.ts +++ b/src/services/modules/menu.ts @@ -18,12 +18,20 @@ export interface MenuOuterResponse { } export const menuApi = { - getMenu: (payload: MenuPayload, options?: RequestOptions) => - httpClient.post('/Menu/GetMenu', payload, { + getMenu: async (payload: MenuPayload, options?: RequestOptions) => ({ + data: await httpClient + .post('Menu/GetMenu', { + json: payload, signal: options?.signal, - }), - getFavorite: (payload: MenuPayload, options?: RequestOptions) => - httpClient.post('/Menu/GetFavorite', payload, { + }) + .json(), + }), + getFavorite: async (payload: MenuPayload, options?: RequestOptions) => ({ + data: await httpClient + .post('Menu/GetFavorite', { + json: payload, signal: options?.signal, - }), + }) + .json(), + }), } diff --git a/src/services/session.ts b/src/services/session.ts index 36573fb..3e80c8a 100644 --- a/src/services/session.ts +++ b/src/services/session.ts @@ -1,7 +1,7 @@ // 全域 Session 事件(Playground 專用) // // 目的: -// - 避免 axios interceptor 直接 import Pinia store / router 造成循環依賴 +// - 避免 HTTP hooks 直接 import Pinia store / router 造成循環依賴 // - 由 App.vue 在 UI 層統一處理登出流程(清狀態、導頁、提示訊息) export const SESSION_FORCE_LOGOUT_EVENT = 'sk-playground:session-force-logout' diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 6dd279b..f1c989c 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -10,7 +10,7 @@ import { useMenuStore } from '@/stores/menu' // - Component 不直接呼叫 API,避免狀態散落 // - token 單一來源:透過 tokenService 同步 ref + localStorage // - store 負責寫入/清除 token(login/logout) -// - axios interceptor 只讀 tokenService +// - HTTP hooks 只讀 tokenService export const useAuthStore = defineStore('auth', () => { const user = ref(null) const token = tokenService.token