diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9030888 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/package.json b/package.json index a2e06da..62ceddc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "format": "oxfmt src/" }, "dependencies": { + "axios": "^1.17.0", "naive-ui": "^2.44.1", "pinia": "^3.0.4", "pinia-plugin-persistedstate": "^4.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2d198c..3e10a8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + axios: + specifier: ^1.17.0 + version: 1.17.0 naive-ui: specifier: ^2.44.1 version: 2.44.1(vue@3.5.35(typescript@6.0.3)) @@ -850,6 +853,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} @@ -872,6 +879,12 @@ packages: async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.17.0: + resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + baseline-browser-mapping@2.10.33: resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} engines: {node: '>=6.0.0'} @@ -892,6 +905,10 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} @@ -902,6 +919,10 @@ 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==} @@ -960,10 +981,18 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + 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'} + electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} @@ -978,6 +1007,22 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + 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.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + 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'} @@ -1007,15 +1052,43 @@ packages: picomatch: optional: true + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + 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.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + 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'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1023,6 +1096,18 @@ packages: 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.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + highlight.js@11.11.1: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} @@ -1030,6 +1115,10 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + immutable@5.1.6: resolution: {integrity: sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ==} @@ -1191,10 +1280,22 @@ 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==} @@ -1318,6 +1419,10 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -2490,6 +2595,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + alien-signals@3.2.1: {} ansi-styles@6.2.3: {} @@ -2509,6 +2620,18 @@ snapshots: async-validator@4.2.5: {} + asynckit@0.4.0: {} + + axios@1.17.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + baseline-browser-mapping@2.10.33: {} birpc@2.9.0: {} @@ -2527,6 +2650,11 @@ snapshots: dependencies: run-applescript: 7.1.0 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + caniuse-lite@1.0.30001793: {} chokidar@5.0.0: @@ -2535,6 +2663,10 @@ 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: {} @@ -2581,8 +2713,16 @@ snapshots: defu@6.1.7: {} + 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 + electron-to-chromium@1.5.364: {} enhanced-resolve@5.22.1: @@ -2594,6 +2734,21 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + 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.4 + escalade@3.2.0: {} escape-string-regexp@5.0.0: {} @@ -2612,19 +2767,68 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + 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.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + graceful-fs@4.2.11: {} has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + highlight.js@11.11.1: {} hookable@5.5.3: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + immutable@5.1.6: {} is-docker@3.0.0: {} @@ -2738,8 +2942,16 @@ 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: @@ -2883,6 +3095,8 @@ snapshots: powershell-utils@0.1.0: {} + proxy-from-env@2.1.0: {} + quansync@0.2.11: {} read-package-json-fast@4.0.0: diff --git a/src/api/request/errorUI.ts b/src/api/request/errorUI.ts new file mode 100644 index 0000000..239433a --- /dev/null +++ b/src/api/request/errorUI.ts @@ -0,0 +1,17 @@ +import { createDiscreteApi } from 'naive-ui' +import { eventBus, AppEvents } from '@/utils/eventBus' +import type { RequestErrorEvent } from './types' + +/** 离散式 API(可在组件外使用) */ +const { message } = createDiscreteApi(['message']) + +/** + * 注册请求错误 UI 展示 + * + * 订阅应用级 REQUEST_ERROR 事件,统一展示错误提示, + * 实现网络模块与 UI 的解耦。 + */ +eventBus.on(AppEvents.REQUEST_ERROR, (payload: unknown) => { + const { message: errorMessage } = payload as RequestErrorEvent + message.error(errorMessage) +}) diff --git a/src/api/request/index.ts b/src/api/request/index.ts new file mode 100644 index 0000000..c5219fd --- /dev/null +++ b/src/api/request/index.ts @@ -0,0 +1,5 @@ +export { requestClient } from './requestClient' +export type { ApiResponse, RequestErrorEvent, RequestOptions } from './types' + +/** 注册错误 UI 展示(副作用导入,应用启动时执行) */ +import './errorUI' diff --git a/src/api/request/requestClient.ts b/src/api/request/requestClient.ts new file mode 100644 index 0000000..06c2783 --- /dev/null +++ b/src/api/request/requestClient.ts @@ -0,0 +1,243 @@ +import axios, { type AxiosInstance, type AxiosRequestConfig, type CreateAxiosDefaults } from 'axios' +import { useAccountStore } from '@/stores' +import { eventBus, AppEvents } from '@/utils/eventBus' +import type { ApiResponse, InternalRequestConfig, RequestErrorEvent } from './types' + +/** + * 替换 URL 中的路径参数占位符 + * + * @example + * resolvePathParams('/user/:id/posts', { id: 1 }) // → '/user/1/posts' + */ +function resolvePathParams(url: string, pathParams?: Record): string { + if (!pathParams) return url + return url.replace(/:(\w+)/g, (_, key) => { + const value = pathParams[key] + if (value === undefined) { + throw new Error(`路径参数 "${key}" 缺失`) + } + return String(value) + }) +} + +/** + * 基于 axios 封装的网络请求客户端 + * + * 不依赖任何 UI 库,错误通过事件总线发布,由外部订阅处理。 + * + * @example + * ```ts + * // GET 请求 + * const res = await requestClient.get('/users', { page: 1 }) + * + * // POST 请求 + * const res = await requestClient.post('/auth/login', { username, password }) + * ``` + */ +class RequestClient { + /** axios 实例 */ + private instance: AxiosInstance + + constructor(config?: CreateAxiosDefaults) { + this.instance = axios.create({ + baseURL: '/api', + timeout: 15000, + headers: { + 'Content-Type': 'application/json' + }, + ...config + }) + + this.setupInterceptors() + } + + /** 注册请求和响应拦截器 */ + private setupInterceptors(): void { + // === 请求拦截器 === + this.instance.interceptors.request.use( + (config) => { + const reqConfig = config as InternalRequestConfig + + // 替换路径参数 :param → 实际值 + if (reqConfig.pathParams && config.url) { + config.url = resolvePathParams(config.url, reqConfig.pathParams) + } + + // 跳过鉴权时不注入 token + if (!reqConfig?.skipAuth) { + const accountStore = useAccountStore() + if (accountStore.token) { + config.headers.Authorization = `Bearer ${accountStore.token}` + } + } + + return config + }, + (error) => { + return Promise.reject(error) + } + ) + + // === 响应拦截器 === + // 拦截器内部将 axiosResponse 解包为 ApiResponse, + // 返回类型与 axios 类型签名不匹配,但公共方法已声明正确返回类型。 + this.instance.interceptors.response.use( + // @ts-expect-error 拦截器解包 axiosResponse → ApiResponse + (response) => { + const data = response.data as ApiResponse + + // 业务成功 + if (data.code === 0) { + return data + } + + // 业务错误事件载荷 + const errorEvent: RequestErrorEvent = { + type: 'business', + code: data.code, + message: data.message || '请求失败' + } + + // 401 → 额外发布未授权事件,解耦路由 + if (data.code === 401) { + eventBus.emit(AppEvents.UNAUTHORIZED) + } + + // 发布请求错误事件,由 errorUI 模块订阅展示 + eventBus.emit(AppEvents.REQUEST_ERROR, errorEvent) + return Promise.reject(data) + }, + (error) => { + let errorEvent: RequestErrorEvent + + if (error.response?.status === 401) { + eventBus.emit(AppEvents.UNAUTHORIZED) + errorEvent = { + type: 'http', + code: 401, + message: '登录已过期,请重新登录' + } + } else if (!error.response) { + errorEvent = { + type: 'network', + message: '网络异常,请检查网络连接' + } + } else { + errorEvent = { + type: 'http', + code: error.response.status, + message: `请求错误 (${error.response.status})` + } + } + + eventBus.emit(AppEvents.REQUEST_ERROR, errorEvent) + return Promise.reject(error) + } + ) + } + + /** + * 通用请求方法 + * + * 适用于路径参数、混合参数等复杂场景。 + * + * @example + * ```ts + * // 路径参数 + 查询参数 + * requestClient.request({ + * method: 'GET', + * url: '/user/:id/posts', + * params: { page: 1 }, + * pathParams: { id: 1 } + * }) + * + * // 路径参数 + 请求体 + * requestClient.request({ + * method: 'POST', + * url: '/user/:id/post', + * data: body, + * pathParams: { id: 1 } + * }) + * + * // 自定义请求头 + 跳过鉴权 + * requestClient.request({ + * method: 'POST', + * url: '/public/upload', + * data: formData, + * headers: { 'Content-Type': 'multipart/form-data' }, + * requestOptions: { skipAuth: true } + * }) + * ``` + */ + request(config: InternalRequestConfig): Promise> { + return this.instance.request(config) + } + + /** + * GET 请求 + * @param url - 请求地址 + * @param params - 查询参数 + * @param config - 额外配置(pathParams、requestOptions 等) + */ + get( + url: string, + params?: Record, + config?: InternalRequestConfig + ): Promise> { + return this.instance.get(url, { params, ...config }) + } + + /** + * POST 请求 + * @param url - 请求地址 + * @param data - 请求体 + * @param config - 额外配置(pathParams、requestOptions 等) + */ + post( + url: string, + data?: unknown, + config?: InternalRequestConfig + ): Promise> { + return this.instance.post(url, data, config) + } + + /** + * PUT 请求 + * @param url - 请求地址 + * @param data - 请求体 + * @param config - 额外配置(pathParams、requestOptions 等) + */ + put( + url: string, + data?: unknown, + config?: InternalRequestConfig + ): Promise> { + return this.instance.put(url, data, config) + } + + /** + * DELETE 请求 + * @param url - 请求地址 + * @param config - 额外配置(pathParams、requestOptions 等) + */ + delete(url: string, config?: InternalRequestConfig): Promise> { + return this.instance.delete(url, config) + } + + /** + * PATCH 请求 + * @param url - 请求地址 + * @param data - 请求体 + * @param config - 额外配置(pathParams、requestOptions 等) + */ + patch( + url: string, + data?: unknown, + config?: InternalRequestConfig + ): Promise> { + return this.instance.patch(url, data, config) + } +} + +/** 请求客户端单例 */ +export const requestClient = new RequestClient() diff --git a/src/api/request/types.ts b/src/api/request/types.ts new file mode 100644 index 0000000..62cedee --- /dev/null +++ b/src/api/request/types.ts @@ -0,0 +1,29 @@ +import type { AxiosRequestConfig } from 'axios' + +/** 后端统一响应格式 */ +export interface ApiResponse { + /** 业务状态码,0 表示成功 */ + code: number + /** 响应消息 */ + message: string + /** 响应数据 */ + data: T +} + +/** 扩展的请求配置,包含自定义选项 */ +export interface InternalRequestConfig extends AxiosRequestConfig { + /** 路径参数,替换 URL 中的 :param 占位符(如 /user/:id → /user/1) */ + pathParams?: Record + /** 跳过鉴权注入(不携带 Authorization 头) */ + skipAuth?: boolean +} + +/** 请求错误事件载荷 */ +export interface RequestErrorEvent { + /** 错误类型 */ + type: 'business' | 'http' | 'network' + /** 业务状态码(business 类型时有效) */ + code?: number + /** 错误消息 */ + message: string +} diff --git a/src/router/index.ts b/src/router/index.ts index b50c303..3bc344d 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,4 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router' +import { eventBus, AppEvents } from '@/utils/eventBus' +import { useAccountStore } from '@/stores' import { createRouterGuard } from './guard.ts' import routes from './routes.ts' @@ -13,4 +15,11 @@ const router = createRouter({ // 创建路由守卫 createRouterGuard(router) +/** 订阅 401 未授权事件:清除 token 并跳转登录页 */ +eventBus.on(AppEvents.UNAUTHORIZED, () => { + const accountStore = useAccountStore() + accountStore.token = '' + router.push('/auth/login') +}) + export default router diff --git a/src/utils/eventBus.ts b/src/utils/eventBus.ts new file mode 100644 index 0000000..e7e70b9 --- /dev/null +++ b/src/utils/eventBus.ts @@ -0,0 +1,91 @@ +/** 事件处理器类型 */ +type EventHandler = (...args: unknown[]) => void + +/** 应用级事件常量 */ +export const AppEvents = { + /** 401 未授权 */ + UNAUTHORIZED: 'app:unauthorized', + /** 请求错误(业务错误 / HTTP 错误 / 网络错误) */ + REQUEST_ERROR: 'app:request:error' +} as const + +/** + * 发布订阅事件总线 + * + * @example + * ```ts + * // 注册事件 + * eventBus.on(AppEvents.UNAUTHORIZED, () => { + * router.push('/auth/login') + * }) + * + * // 发布事件 + * eventBus.emit(AppEvents.UNAUTHORIZED) + * ``` + */ +class EventBus { + /** 事件名 → 处理器集合 */ + private handlers = new Map>() + + /** + * 注册事件处理器 + * @param event - 事件名 + * @param handler - 处理器函数 + */ + on(event: string, handler: EventHandler): void { + if (!this.handlers.has(event)) { + this.handlers.set(event, new Set()) + } + this.handlers.get(event)!.add(handler) + } + + /** + * 注册一次性事件处理器(触发后自动取消注册) + * @param event - 事件名 + * @param handler - 处理器函数 + */ + once(event: string, handler: EventHandler): void { + const wrapper: EventHandler = (...args: unknown[]) => { + handler(...args) + this.off(event, wrapper) + } + this.on(event, wrapper) + } + + /** + * 取消注册事件处理器 + * @param event - 事件名 + * @param handler - 要取消的处理器函数 + */ + off(event: string, handler: EventHandler): void { + this.handlers.get(event)?.delete(handler) + } + + /** + * 发布事件 + * @param event - 事件名 + * @param args - 传递给处理器的参数 + */ + emit(event: string, ...args: unknown[]): void { + const handlers = this.handlers.get(event) + if (!handlers) return + handlers.forEach((handler) => { + handler(...args) + }) + } + + /** + * 清除指定事件的所有处理器,或清除所有事件 + * @param event - 可选,不传则清除全部 + */ + clear(event?: string): void { + if (event) { + this.handlers.delete(event) + } else { + this.handlers.clear() + } + } +} + +/** 事件总线单例 */ +export const eventBus = new EventBus() diff --git a/src/views/auth/login.vue b/src/views/auth/login.vue index a998695..8e07623 100644 --- a/src/views/auth/login.vue +++ b/src/views/auth/login.vue @@ -64,14 +64,34 @@ const rules: FormRules = { { required: true, message: '请输入登陆账号', - trigger: ['blur'] + trigger: ['blur'], + validator: (rule: FormItemRule, value: string) => { + if (value.length < 2) { + return new Error('账号长度不能小于2位') + } else if (value.length > 20) { + return new Error('账号长度不能大于20位') + } else if (!/^[A-Za-z][A-Za-z0-9]*$/.test(value)) { + return new Error('账号以字母开头,只能包含字母和数字') + } + return true + } } ], password: [ { required: true, message: '请输入登陆密码', - trigger: ['blur'] + trigger: ['blur'], + validator: (rule: FormItemRule, value: string) => { + if (value.length < 6) { + return new Error('密码长度不能小于6位') + } else if (value.length > 20) { + return new Error('密码长度不能大于20位') + } else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/.test(value)) { + return new Error('密码必须包含大小写字母和数字') + } + return true + } } ] }