feat(web): 搭建应用骨架并完善开发环境配置
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
||||
import { envConfig } from '@/config/env';
|
||||
import { authToken } from '@/services/authToken';
|
||||
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
/** 401 刷新重放标记,防止无限重试 */
|
||||
_retried?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: envConfig.apiBaseUrl,
|
||||
timeout: 30000,
|
||||
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
|
||||
// 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。
|
||||
});
|
||||
|
||||
// access token 仅在内存中短暂持有(docs/api/conventions.md §1),每次请求动态附加 Bearer 头
|
||||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = authToken.get();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 认证接口自身的失败不进入刷新流程
|
||||
const AUTH_PATHS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout'];
|
||||
|
||||
let refreshTask: Promise<string | null> | null = null;
|
||||
|
||||
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
|
||||
function refreshAccessToken(): Promise<string | null> {
|
||||
refreshTask ??= apiClient
|
||||
.post<{ access_token: string }>('/auth/refresh')
|
||||
.then((res) => {
|
||||
authToken.set(res.data.access_token);
|
||||
return res.data.access_token;
|
||||
})
|
||||
.catch(() => {
|
||||
authToken.clear();
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshTask = null;
|
||||
});
|
||||
return refreshTask;
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (envConfig.debug) {
|
||||
console.error('请求失败:', error.response?.data || error.message);
|
||||
}
|
||||
const config = error.config as AxiosRequestConfig | undefined;
|
||||
const url = config?.url ?? '';
|
||||
const isAuthPath = AUTH_PATHS.some((path) => url.includes(path));
|
||||
// access token 过期时用 refresh cookie 换新 token 并重放一次;
|
||||
// 刷新失败说明会话已失效,整页跳转登录页(后续可换成路由内跳转并保留来源)
|
||||
if (error.response?.status === 401 && config && !config._retried && !isAuthPath) {
|
||||
config._retried = true;
|
||||
const token = await refreshAccessToken();
|
||||
if (token) {
|
||||
return apiClient.request(config);
|
||||
}
|
||||
window.location.assign('/login');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.get<T>(url, config).then((res) => res.data),
|
||||
|
||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.post<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.patch<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.delete<T>(url, config).then((res) => res.data),
|
||||
};
|
||||
|
||||
/** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */
|
||||
export function apiErrorMessage(error: unknown): string {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data as
|
||||
{ error?: { message?: unknown }; message?: unknown } | undefined;
|
||||
if (data?.error?.message) return String(data.error.message);
|
||||
if (data?.message) return String(data.message);
|
||||
if (error.response) return `请求失败(HTTP ${error.response.status})`;
|
||||
return '网络连接失败,请稍后重试';
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { api } from '@/services/api';
|
||||
import type { AuthSession, LoginPayload, MeResult, RegisterPayload } from '@/types/auth';
|
||||
|
||||
/** 账户与会话接口,契约见 docs/api/auth.md */
|
||||
export const authApi = {
|
||||
register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload),
|
||||
|
||||
login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload),
|
||||
|
||||
logout: () => api.post<void>('/auth/logout'),
|
||||
|
||||
me: () => api.get<MeResult>('/auth/me'),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* access token 仅在内存中短暂持有(docs/api/conventions.md §1),
|
||||
* 不得写入 localStorage 等持久化存储;页面刷新后由 /auth/refresh 通过
|
||||
* HttpOnly cookie 恢复会话(接入会话恢复逻辑时实现)。
|
||||
*/
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export const authToken = {
|
||||
get: () => accessToken,
|
||||
set: (token: string) => {
|
||||
accessToken = token;
|
||||
},
|
||||
clear: () => {
|
||||
accessToken = null;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user