refactor: ky
This commit is contained in:
@@ -99,7 +99,9 @@ export interface ReportSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const reportsApi = {
|
export const reportsApi = {
|
||||||
list: () => httpClient.get<ReportSummary[]>('/Reports'),
|
list: async () => ({
|
||||||
|
data: await httpClient.get('Reports').json<ReportSummary[]>(),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ route 集中放在 `src/router/routes.ts`。不要在 view 或 component 裡臨
|
|||||||
|
|
||||||
store 檔案直接放在 `src/stores/*.ts`。不要建立 `src/stores/stores/*` 或其他重複巢狀 store 目錄。
|
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。
|
4. store 或 composable 呼叫 service。
|
||||||
5. service 回傳資料,不持有 UI 狀態。
|
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。
|
||||||
|
|
||||||
## 環境變數規則
|
## 環境變數規則
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
import 'axios'
|
|
||||||
import 'vue-router'
|
import 'vue-router'
|
||||||
|
|
||||||
declare module '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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"axios": "^1.13.6",
|
"ky": "^2.0.2",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.31",
|
"vue": "^3.5.31",
|
||||||
"vue-i18n": "^11.3.0",
|
"vue-i18n": "^11.3.0",
|
||||||
|
|||||||
Generated
+9
-190
@@ -11,9 +11,9 @@ importers:
|
|||||||
'@mdi/js':
|
'@mdi/js':
|
||||||
specifier: ^7.4.47
|
specifier: ^7.4.47
|
||||||
version: 7.4.47
|
version: 7.4.47
|
||||||
axios:
|
ky:
|
||||||
specifier: ^1.13.6
|
specifier: ^2.0.2
|
||||||
version: 1.13.6
|
version: 2.0.2
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3))
|
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==}
|
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
asynckit@0.4.0:
|
|
||||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
|
||||||
|
|
||||||
axe-core@4.11.1:
|
axe-core@4.11.1:
|
||||||
resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
|
resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
axios@1.13.6:
|
|
||||||
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
|
|
||||||
|
|
||||||
birpc@2.9.0:
|
birpc@2.9.0:
|
||||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
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:
|
chokidar@4.0.3:
|
||||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||||
engines: {node: '>= 14.16.0'}
|
engines: {node: '>= 14.16.0'}
|
||||||
@@ -519,10 +509,6 @@ packages:
|
|||||||
colorjs.io@0.5.2:
|
colorjs.io@0.5.2:
|
||||||
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
|
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:
|
confbox@0.1.8:
|
||||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||||
|
|
||||||
@@ -549,18 +535,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
delayed-stream@1.0.0:
|
|
||||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
|
||||||
engines: {node: '>=0.4.0'}
|
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
|
||||||
engines: {node: '>= 0.4'}
|
|
||||||
|
|
||||||
emoji-regex@10.6.0:
|
emoji-regex@10.6.0:
|
||||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||||
|
|
||||||
@@ -568,22 +546,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
engines: {node: '>=0.12'}
|
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:
|
escalade@3.2.0:
|
||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -603,19 +565,6 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
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:
|
fsevents@2.3.2:
|
||||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
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}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
function-bind@1.1.2:
|
|
||||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
|
||||||
|
|
||||||
get-caller-file@2.0.5:
|
get-caller-file@2.0.5:
|
||||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
engines: {node: 6.* || 8.* || >= 10.*}
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
@@ -637,34 +583,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
|
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
|
||||||
engines: {node: '>=18'}
|
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:
|
has-flag@4.0.0:
|
||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
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:
|
hookable@5.5.3:
|
||||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||||
|
|
||||||
@@ -708,6 +630,10 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ky@2.0.2:
|
||||||
|
resolution: {integrity: sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==}
|
||||||
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -793,22 +719,10 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
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:
|
memorystream@0.3.1:
|
||||||
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
|
||||||
engines: {node: '>= 0.10.0'}
|
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:
|
mitt@3.0.1:
|
||||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||||
|
|
||||||
@@ -900,9 +814,6 @@ packages:
|
|||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
proxy-from-env@1.1.0:
|
|
||||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
|
||||||
|
|
||||||
quansync@0.2.11:
|
quansync@0.2.11:
|
||||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||||
|
|
||||||
@@ -1658,25 +1569,10 @@ snapshots:
|
|||||||
'@babel/parser': 7.29.2
|
'@babel/parser': 7.29.2
|
||||||
ast-kit: 2.2.0
|
ast-kit: 2.2.0
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
|
||||||
|
|
||||||
axe-core@4.11.1: {}
|
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: {}
|
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:
|
chokidar@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
readdirp: 4.1.2
|
readdirp: 4.1.2
|
||||||
@@ -1694,10 +1590,6 @@ snapshots:
|
|||||||
|
|
||||||
colorjs.io@0.5.2: {}
|
colorjs.io@0.5.2: {}
|
||||||
|
|
||||||
combined-stream@1.0.8:
|
|
||||||
dependencies:
|
|
||||||
delayed-stream: 1.0.0
|
|
||||||
|
|
||||||
confbox@0.1.8: {}
|
confbox@0.1.8: {}
|
||||||
|
|
||||||
confbox@0.2.4: {}
|
confbox@0.2.4: {}
|
||||||
@@ -1718,35 +1610,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
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: {}
|
emoji-regex@10.6.0: {}
|
||||||
|
|
||||||
entities@7.0.1: {}
|
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: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
estree-walker@2.0.2: {}
|
estree-walker@2.0.2: {}
|
||||||
@@ -1757,60 +1626,18 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
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:
|
fsevents@2.3.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
function-bind@1.1.2: {}
|
|
||||||
|
|
||||||
get-caller-file@2.0.5: {}
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
get-east-asian-width@1.5.0: {}
|
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-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: {}
|
hookable@5.5.3: {}
|
||||||
|
|
||||||
immutable@5.1.5: {}
|
immutable@5.1.5: {}
|
||||||
@@ -1839,6 +1666,8 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
ky@2.0.2: {}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1902,16 +1731,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
|
||||||
|
|
||||||
memorystream@0.3.1: {}
|
memorystream@0.3.1: {}
|
||||||
|
|
||||||
mime-db@1.52.0: {}
|
|
||||||
|
|
||||||
mime-types@2.1.35:
|
|
||||||
dependencies:
|
|
||||||
mime-db: 1.52.0
|
|
||||||
|
|
||||||
mitt@3.0.1: {}
|
mitt@3.0.1: {}
|
||||||
|
|
||||||
mlly@1.8.2:
|
mlly@1.8.2:
|
||||||
@@ -1994,8 +1815,6 @@ snapshots:
|
|||||||
|
|
||||||
prettier@3.8.1: {}
|
prettier@3.8.1: {}
|
||||||
|
|
||||||
proxy-from-env@1.1.0: {}
|
|
||||||
|
|
||||||
quansync@0.2.11: {}
|
quansync@0.2.11: {}
|
||||||
|
|
||||||
read-package-json-fast@4.0.0:
|
read-package-json-fast@4.0.0:
|
||||||
|
|||||||
+20
-8
@@ -1,24 +1,24 @@
|
|||||||
# Services
|
# Services
|
||||||
|
|
||||||
`src/services` 是資料存取與 HTTP 邊界,負責封裝 axios client、interceptor、token/session、錯誤處理與 API 模組。
|
`src/services` 是資料存取與 HTTP 邊界,負責封裝 ky client、hooks、token/session、錯誤處理與 API 模組。
|
||||||
|
|
||||||
## 目前資料流
|
## 目前資料流
|
||||||
|
|
||||||
```txt
|
```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。
|
- store 或 composable 負責協調 UI 狀態與呼叫 service。
|
||||||
- service 回傳資料,不持有 UI 狀態。
|
- service 回傳資料,不持有 UI 狀態。
|
||||||
- service 不 import component、view 或 store。
|
- service 不 import component、view 或 store。
|
||||||
|
|
||||||
## 目前檔案
|
## 目前檔案
|
||||||
|
|
||||||
- `client.ts`:建立單一 axios instance,設定 `baseURL`、timeout、credentials 與 interceptor。
|
- `client.ts`:建立單一 ky instance,設定 `prefix`、timeout、credentials 與 hooks。
|
||||||
- `interceptors.ts`:集中處理 request token 注入與 response 錯誤。
|
- `interceptors.ts`:集中提供 ky hooks,處理 request token 注入與 response 錯誤。
|
||||||
- `error.ts`:提供 `normalizeError()` 與統一錯誤型別。
|
- `error.ts`:提供 `normalizeError()` 與統一錯誤型別。
|
||||||
- `http-error.ts`:提供全域 HTTP 錯誤事件。
|
- `http-error.ts`:提供全域 HTTP 錯誤事件。
|
||||||
- `http-toast.ts`:提供 HTTP 錯誤提示相關流程。
|
- `http-toast.ts`:提供 HTTP 錯誤提示相關流程。
|
||||||
@@ -38,6 +38,18 @@ API module 應:
|
|||||||
- 定義與該 module 相關的 request/response 型別。
|
- 定義與該 module 相關的 request/response 型別。
|
||||||
- 接收 `AbortSignal` 等 request option,但不管理頁面 loading 或 controller 狀態。
|
- 接收 `AbortSignal` 等 request option,但不管理頁面 loading 或 controller 狀態。
|
||||||
|
|
||||||
|
## ky 使用注意事項
|
||||||
|
|
||||||
|
本專案使用 ky,不使用 axios。新增或調整 API module 時注意:
|
||||||
|
|
||||||
|
- ky 不回傳 axios 的 `{ data, status, headers }` 物件。需要 JSON 時使用 `.json<T>()`。
|
||||||
|
- 若呼叫端已經依賴 `{ 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 設定
|
## HTTP Client 設定
|
||||||
|
|
||||||
`client.ts` 的 `baseURL` 優先使用 `VITE_API_BASE_URL`,否則使用 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
|
`client.ts` 的 `baseURL` 優先使用 `VITE_API_BASE_URL`,否則使用 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
|
||||||
@@ -64,10 +76,10 @@ production 不應沿用 template 內的示範後端位址,應由使用專案
|
|||||||
token 由 `tokenService` 作為單一來源:
|
token 由 `tokenService` 作為單一來源:
|
||||||
|
|
||||||
- store 負責登入成功後寫入 token,以及登出時清除 token。
|
- store 負責登入成功後寫入 token,以及登出時清除 token。
|
||||||
- interceptor 只讀取 token 並附加到 request。
|
- hooks 只讀取 token 並附加到 request。
|
||||||
- 401 或 HTTP 錯誤由 interceptor 與錯誤事件流程集中處理。
|
- 401 或 HTTP 錯誤由 hooks 與錯誤事件流程集中處理。
|
||||||
|
|
||||||
錯誤透過 `normalizeError()` 轉成 UI 可理解的格式。UI 或 store 不需要直接理解 AxiosError。
|
錯誤透過 `normalizeError()` 轉成 UI 可理解的格式。UI 或 store 不需要直接理解 ky 的 HTTPError。
|
||||||
|
|
||||||
## 請求取消
|
## 請求取消
|
||||||
|
|
||||||
|
|||||||
+7
-14
@@ -1,21 +1,14 @@
|
|||||||
import axios, { type AxiosInstance } from 'axios'
|
import ky, { type KyInstance } from 'ky'
|
||||||
import { setupInterceptors } from './interceptors'
|
import { createHooks } from './interceptors'
|
||||||
|
|
||||||
// HTTP Client(Axios instance)
|
function createClient(): KyInstance {
|
||||||
//
|
|
||||||
// 設計重點:
|
|
||||||
// - 透過單一 axios instance 統一管理 baseURL、timeout、headers 與攔截器
|
|
||||||
// - 預設 baseURL 使用 `/api`:搭配 Vite proxy 轉送到後端 dev server
|
|
||||||
// - 攔截器集中在 `interceptors.ts`,避免 client.ts 變得難維護
|
|
||||||
function createClient(): AxiosInstance {
|
|
||||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '/service/api'
|
const baseURL = import.meta.env.VITE_API_BASE_URL || '/service/api'
|
||||||
const client = axios.create({
|
return ky.create({
|
||||||
baseURL,
|
prefix: baseURL,
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
withCredentials: true,
|
credentials: 'include',
|
||||||
|
hooks: createHooks(),
|
||||||
})
|
})
|
||||||
setupInterceptors(client)
|
|
||||||
return client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const httpClient = createClient()
|
export const httpClient = createClient()
|
||||||
|
|||||||
+11
-13
@@ -1,5 +1,5 @@
|
|||||||
import type { ApiError } from '@/types/api'
|
import type { ApiError } from '@/types/api'
|
||||||
import { isAxiosError } from 'axios'
|
import { isHTTPError, isTimeoutError } from 'ky'
|
||||||
|
|
||||||
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)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
@@ -51,12 +51,6 @@ export function extractErrorMessage(data: unknown): string | undefined {
|
|||||||
return firstString(data)
|
return firstString(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 統一錯誤格式
|
|
||||||
//
|
|
||||||
// 設計重點:
|
|
||||||
// - 將 AxiosError 與非預期錯誤統一轉成 ApiRequestError
|
|
||||||
// - Store 只需要處理 message/code/status,不需理解 Axios 結構
|
|
||||||
// - 取消請求(AbortController)會轉成 CanceledRequestError
|
|
||||||
export class ApiRequestError extends Error {
|
export class ApiRequestError extends Error {
|
||||||
code?: number
|
code?: number
|
||||||
status?: number
|
status?: number
|
||||||
@@ -87,9 +81,6 @@ 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'
|
return error instanceof DOMException && error.name === 'AbortError'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +93,9 @@ export function normalizeError(error: unknown): ApiRequestError {
|
|||||||
return new CanceledRequestError()
|
return new CanceledRequestError()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAxiosError(error)) {
|
if (isHTTPError(error)) {
|
||||||
const status = error.response?.status
|
const status = error.response.status
|
||||||
const data = error.response?.data as unknown
|
const data = error.data
|
||||||
const message = extractErrorMessage(data) || error.message || '請求失敗'
|
const message = extractErrorMessage(data) || error.message || '請求失敗'
|
||||||
const apiError = isRecord(data) ? (data as Partial<ApiError>) : undefined
|
const apiError = isRecord(data) ? (data as Partial<ApiError>) : undefined
|
||||||
const code = apiError?.code ?? status
|
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) {
|
if (error instanceof Error) {
|
||||||
return new ApiRequestError({ message: error.message, raw: error })
|
return new ApiRequestError({ message: error.message, raw: error })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 全域 HTTP 錯誤事件(Playground 專用)
|
// 全域 HTTP 錯誤事件(Playground 專用)
|
||||||
//
|
//
|
||||||
// 目的:
|
// 目的:
|
||||||
// - 避免 axios interceptor 直接 import router 造成耦合
|
// - 避免 HTTP hooks 直接 import router 造成耦合
|
||||||
// - 由 router 層或 App 層決定要導到哪個錯誤頁與顯示哪些訊息
|
// - 由 router 層或 App 層決定要導到哪個錯誤頁與顯示哪些訊息
|
||||||
|
|
||||||
export const HTTP_ERROR_EVENT = 'sk-playground:http-error'
|
export const HTTP_ERROR_EVENT = 'sk-playground:http-error'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 全域 HTTP Toast 事件(Playground 專用)
|
// 全域 HTTP Toast 事件(Playground 專用)
|
||||||
//
|
//
|
||||||
// 目的:
|
// 目的:
|
||||||
// - 讓 axios interceptor 能用「事件」通知 UI 顯示錯誤提示,不需直接依賴 Pinia
|
// - 讓 HTTP hooks 能用「事件」通知 UI 顯示錯誤提示,不需直接依賴 Pinia
|
||||||
// - 預設只用於「非阻斷」錯誤(例如 500 / 網路中斷),避免導頁打斷使用者
|
// - 預設只用於「非阻斷」錯誤(例如 500 / 網路中斷),避免導頁打斷使用者
|
||||||
|
|
||||||
export const HTTP_TOAST_EVENT = 'sk-playground:http-toast'
|
export const HTTP_TOAST_EVENT = 'sk-playground:http-toast'
|
||||||
|
|||||||
@@ -1,104 +1,65 @@
|
|||||||
import { type AxiosError, AxiosHeaders, type AxiosInstance } from 'axios'
|
import type { Hooks } from 'ky'
|
||||||
|
import { isHTTPError } from 'ky'
|
||||||
import { extractErrorMessage, normalizeError } from './error'
|
import { extractErrorMessage, normalizeError } from './error'
|
||||||
import { emitHttpError } from './http-error'
|
import { emitHttpError } from './http-error'
|
||||||
import { emitHttpToast } from './http-toast'
|
import { emitHttpToast } from './http-toast'
|
||||||
import { emitForceLogout } from './session'
|
import { emitForceLogout } from './session'
|
||||||
import { tokenService } from './token'
|
import { tokenService } from './token'
|
||||||
|
|
||||||
// Axios 攔截器
|
export function createHooks(): Hooks {
|
||||||
//
|
return {
|
||||||
// 設計重點:
|
beforeRequest: [
|
||||||
// - Request:自動注入 token(從 localStorage 讀取)
|
({ request }) => {
|
||||||
// - 使用 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 token = tokenService.getToken()
|
||||||
const url = config.url ?? ''
|
const url = request.url
|
||||||
const shouldAttachToken = !url.includes('/Auth/login')
|
const shouldAttachToken = !url.includes('/Auth/login')
|
||||||
|
|
||||||
if (token && shouldAttachToken) {
|
if (token && shouldAttachToken) {
|
||||||
const headers = AxiosHeaders.from(config.headers ?? {})
|
request.headers.set('Authorization', `Bearer ${token}`)
|
||||||
headers.set('Authorization', `Bearer ${token}`)
|
|
||||||
config.headers = headers
|
|
||||||
}
|
}
|
||||||
return config
|
|
||||||
},
|
},
|
||||||
(error) => {
|
],
|
||||||
return Promise.reject(error)
|
beforeError: [
|
||||||
}
|
({ request, options, error }) => {
|
||||||
)
|
|
||||||
|
|
||||||
// Response: 統一錯誤處理
|
|
||||||
client.interceptors.response.use(
|
|
||||||
(response) => response,
|
|
||||||
(error: AxiosError) => {
|
|
||||||
const normalized = normalizeError(error)
|
const normalized = normalizeError(error)
|
||||||
|
|
||||||
// 取消請求不做全域錯誤導頁
|
if (normalized.name === 'CanceledRequestError') {
|
||||||
if (error.code === 'ERR_CANCELED') {
|
return normalized
|
||||||
return Promise.reject(normalized)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestUrl = error.config?.url ?? ''
|
const requestUrl = request.url
|
||||||
const isLoginRequest = requestUrl.includes('/Auth/login')
|
const isLoginRequest = requestUrl.includes('/Auth/login')
|
||||||
const silentToast = Boolean(error.config?.meta?.silentToast)
|
const silentToast = Boolean(options.context.silentToast)
|
||||||
|
const status = isHTTPError(error) ? error.response.status : undefined
|
||||||
|
|
||||||
// 統一處理 HTTP 狀態碼錯誤
|
|
||||||
const status = error.response?.status
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
// 401 Unauthorized
|
|
||||||
case 401: {
|
case 401: {
|
||||||
// 不是所有 401 都代表「token 過期」:
|
if (requestUrl.includes('/Auth/login')) break
|
||||||
// - 登入失敗通常也會是 401,但不應觸發全域登出流程
|
|
||||||
// 這裡以「是否帶 Authorization header」作為判斷依據
|
|
||||||
{
|
|
||||||
const url = error.config?.url ?? ''
|
|
||||||
if (url.includes('/Auth/login')) break
|
|
||||||
|
|
||||||
const requestHeaders = AxiosHeaders.from(error.config?.headers ?? {})
|
const hasAuthHeader = request.headers.has('Authorization')
|
||||||
const hasAuthHeader = Boolean(requestHeaders.get('Authorization'))
|
|
||||||
if (hasAuthHeader) {
|
if (hasAuthHeader) {
|
||||||
tokenService.clearToken()
|
tokenService.clearToken()
|
||||||
const backendMessage = extractErrorMessage(error.response?.data)
|
const backendMessage = isHTTPError(error) ? extractErrorMessage(error.data) : undefined
|
||||||
emitForceLogout({ message: backendMessage })
|
emitForceLogout({ message: backendMessage })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// 403 Forbidden
|
|
||||||
case 403: {
|
case 403: {
|
||||||
emitHttpError({ status, message: normalized.message })
|
emitHttpError({ status, message: normalized.message })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// 404 Not Found(API 端點不存在/被移除)
|
|
||||||
case 404: {
|
case 404: {
|
||||||
emitHttpError({ status, message: normalized.message })
|
emitHttpError({ status, message: normalized.message })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// 500 Internal Server Error
|
|
||||||
case 500: {
|
case 500: {
|
||||||
// 500 通常是「單一 API 失敗」:交由呼叫端決定 UI(snackbar/區塊錯誤/重試)
|
|
||||||
// 避免同一頁多支 API 時,其中一支 500 就把整個頁面導走
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// 503 Service Unavailable
|
|
||||||
case 503: {
|
case 503: {
|
||||||
// 503 通常對使用者來說就是「系統維護/暫時無法使用」
|
|
||||||
emitHttpError({ status, message: normalized.message })
|
emitHttpError({ status, message: normalized.message })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// 無 response status 時,多半是網路/跨網域/連線問題:
|
|
||||||
// 交由呼叫端決定 UI(snackbar/區塊錯誤/重試),避免全域導頁打斷使用者操作
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldToast =
|
const shouldToast =
|
||||||
@@ -116,7 +77,9 @@ export function setupInterceptors(client: AxiosInstance) {
|
|||||||
message: normalized.message,
|
message: normalized.message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Promise.reject(normalized)
|
|
||||||
|
return normalized
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ export interface RequestOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
getCaptcha: () => httpClient.get<CaptchaResponse>('/Auth/get-captcha'),
|
getCaptcha: async () => ({
|
||||||
login: (payload: FormData, options?: RequestOptions) =>
|
data: await httpClient.get('Auth/get-captcha').json<CaptchaResponse>(),
|
||||||
httpClient.post<unknown>('/Auth/login', payload, {
|
}),
|
||||||
|
login: async (payload: FormData, options?: RequestOptions) => ({
|
||||||
|
data: await httpClient
|
||||||
|
.post('Auth/login', {
|
||||||
|
body: payload,
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
|
})
|
||||||
|
.json<unknown>(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ export interface MenuOuterResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const menuApi = {
|
export const menuApi = {
|
||||||
getMenu: (payload: MenuPayload, options?: RequestOptions) =>
|
getMenu: async (payload: MenuPayload, options?: RequestOptions) => ({
|
||||||
httpClient.post<MenuOuterResponse>('/Menu/GetMenu', payload, {
|
data: await httpClient
|
||||||
|
.post('Menu/GetMenu', {
|
||||||
|
json: payload,
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
|
})
|
||||||
|
.json<MenuOuterResponse>(),
|
||||||
}),
|
}),
|
||||||
getFavorite: (payload: MenuPayload, options?: RequestOptions) =>
|
getFavorite: async (payload: MenuPayload, options?: RequestOptions) => ({
|
||||||
httpClient.post<MenuOuterResponse>('/Menu/GetFavorite', payload, {
|
data: await httpClient
|
||||||
|
.post('Menu/GetFavorite', {
|
||||||
|
json: payload,
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
|
})
|
||||||
|
.json<MenuOuterResponse>(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 全域 Session 事件(Playground 專用)
|
// 全域 Session 事件(Playground 專用)
|
||||||
//
|
//
|
||||||
// 目的:
|
// 目的:
|
||||||
// - 避免 axios interceptor 直接 import Pinia store / router 造成循環依賴
|
// - 避免 HTTP hooks 直接 import Pinia store / router 造成循環依賴
|
||||||
// - 由 App.vue 在 UI 層統一處理登出流程(清狀態、導頁、提示訊息)
|
// - 由 App.vue 在 UI 層統一處理登出流程(清狀態、導頁、提示訊息)
|
||||||
|
|
||||||
export const SESSION_FORCE_LOGOUT_EVENT = 'sk-playground:session-force-logout'
|
export const SESSION_FORCE_LOGOUT_EVENT = 'sk-playground:session-force-logout'
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import { useMenuStore } from '@/stores/menu'
|
|||||||
// - Component 不直接呼叫 API,避免狀態散落
|
// - Component 不直接呼叫 API,避免狀態散落
|
||||||
// - token 單一來源:透過 tokenService 同步 ref + localStorage
|
// - token 單一來源:透過 tokenService 同步 ref + localStorage
|
||||||
// - store 負責寫入/清除 token(login/logout)
|
// - store 負責寫入/清除 token(login/logout)
|
||||||
// - axios interceptor 只讀 tokenService
|
// - HTTP hooks 只讀 tokenService
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<User | null>(null)
|
const user = ref<User | null>(null)
|
||||||
const token = tokenService.token
|
const token = tokenService.token
|
||||||
|
|||||||
Reference in New Issue
Block a user