diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1dea2ca..cfa2483 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", "@vitejs/plugin-react": "^4.3.4", + "axios-mock-adapter": "^2.1.0", "eslint": "^8.57.1", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.16", @@ -2685,6 +2686,20 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/axios-mock-adapter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3960,6 +3975,30 @@ "dev": true, "license": "ISC" }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5c66676..bddbe7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", "@vitejs/plugin-react": "^4.3.4", + "axios-mock-adapter": "^2.1.0", "eslint": "^8.57.1", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.16", diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts new file mode 100644 index 0000000..57635ea --- /dev/null +++ b/frontend/src/api/request.ts @@ -0,0 +1,73 @@ +// REQ-USR-004: 统一 Axios 实例 + Result 拆包 + 错误拦截(docs/04 § 2.3 / § 2.4) +import axios, { + AxiosError, + type AxiosInstance, + type InternalAxiosRequestConfig, +} from 'axios'; + +/** token 持久化键名(D6,跨 task 引用此常量,不写字面量) */ +export const TOKEN_STORAGE_KEY = 'xly_erp_token'; + +/** 网络 / 超时 / 5xx 无可用业务码时的统一标识 */ +export const NETWORK_ERROR_CODE = -1; + +/** 后端统一响应体 Result(docs/04 § 1.4) */ +export interface Result { + code: number; + message: string; + data: T; +} + +/** 业务 / 网络错误,携带后端业务码供页面分流文案 */ +export class ApiError extends Error { + code: number; + constructor(code: number, message: string) { + super(message); + this.name = 'ApiError'; + this.code = code; + } +} + +const request: AxiosInstance = axios.create({ + baseURL: '/api', + timeout: 15000, + headers: { 'Content-Type': 'application/json' }, +}); + +// 请求拦截器:已登录态注入 Authorization;登录 / 版本端点无 token 时自然跳过 +request.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem(TOKEN_STORAGE_KEY); + if (token) { + config.headers.set('Authorization', `Bearer ${token}`); + } + return config; +}); + +// 响应拦截器:拆 Result。code=0 取 data;非 0 抛 ApiError;无响应(网络/超时/5xx)兜底 +request.interceptors.response.use( + (response) => { + const body = response.data as Result; + if (body && typeof body === 'object' && 'code' in body) { + if (body.code === 0) { + return body.data; + } + return Promise.reject(new ApiError(body.code, body.message || '请求失败')); + } + // 非 Result 结构(理论上不应出现),原样返回 data + return response.data; + }, + (error: AxiosError) => { + // 已是 ApiError 直接透传(极少数情况) + if (error instanceof ApiError) { + return Promise.reject(error); + } + const body = error.response?.data as Result | undefined; + if (body && typeof body === 'object' && 'code' in body) { + return Promise.reject(new ApiError(body.code, body.message || '请求失败')); + } + // 无响应:网络异常 / 超时 / 5xx 无 Result 体 + return Promise.reject(new ApiError(NETWORK_ERROR_CODE, '网络异常,请稍后重试')); + }, +); + +export default request; diff --git a/frontend/tests/unit/request.test.ts b/frontend/tests/unit/request.test.ts new file mode 100644 index 0000000..908ad2c --- /dev/null +++ b/frontend/tests/unit/request.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import MockAdapter from 'axios-mock-adapter'; +import request, { ApiError, TOKEN_STORAGE_KEY, NETWORK_ERROR_CODE } from '../../src/api/request'; + +describe('request (Axios 实例)', () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(request); + localStorage.clear(); + }); + + it('baseURL is /api', () => { + expect(request.defaults.baseURL).toBe('/api'); + }); + + it('unwraps data when code is 0', async () => { + mock.onGet('/ping').reply(200, { code: 0, message: 'success', data: { foo: 1 } }); + const data = await request.get('/ping'); + expect(data).toEqual({ foo: 1 }); + }); + + it('throws ApiError carrying business code when code is non-zero', async () => { + mock.onPost('/usr/login').reply(200, { code: 40101, message: '认证失败', data: null }); + await expect(request.post('/usr/login', {})).rejects.toMatchObject({ + name: 'ApiError', + code: 40101, + }); + await expect(request.post('/usr/login', {})).rejects.toBeInstanceOf(ApiError); + }); + + it('throws network ApiError on no-response error', async () => { + mock.onGet('/usr/companies').networkError(); + await expect(request.get('/usr/companies')).rejects.toMatchObject({ + name: 'ApiError', + code: NETWORK_ERROR_CODE, + }); + }); + + it('injects Authorization header when token present', async () => { + localStorage.setItem(TOKEN_STORAGE_KEY, 'tk-123'); + let seenAuth: string | undefined; + mock.onGet('/usr/users').reply((config) => { + seenAuth = config.headers?.Authorization as string | undefined; + return [200, { code: 0, message: 'success', data: [] }]; + }); + await request.get('/usr/users'); + expect(seenAuth).toBe('Bearer tk-123'); + }); + + it('does not inject Authorization header when no token', async () => { + let seenAuth: string | undefined; + mock.onGet('/usr/companies').reply((config) => { + seenAuth = config.headers?.Authorization as string | undefined; + return [200, { code: 0, message: 'success', data: [] }]; + }); + await request.get('/usr/companies'); + expect(seenAuth).toBeUndefined(); + }); +});