feat(web): 初始化前端项目

This commit is contained in:
R524809
2026-09-03 13:11:38 +08:00
parent 195b6d25e9
commit df40ddde8c
31 changed files with 948 additions and 848 deletions
+63 -63
View File
@@ -4,26 +4,26 @@ import { envConfig } from '@/config/env';
import { authToken } from '@/services/authToken';
declare module 'axios' {
export interface AxiosRequestConfig {
/** 401 刷新重放标记,防止无限重试 */
_retried?: boolean;
}
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 头。
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 token = authToken.get();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 认证接口自身的失败不进入刷新流程
@@ -33,70 +33,70 @@ 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;
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);
(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);
}
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),
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),
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),
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),
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);
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;
+4 -4
View File
@@ -3,11 +3,11 @@ import type { AuthSession, LoginPayload, MeResult, RegisterPayload } from '@/typ
/** 账户与会话接口,契约见 docs/api/auth.md */
export const authApi = {
register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload),
register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload),
login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload),
login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload),
logout: () => api.post<void>('/auth/logout'),
logout: () => api.post<void>('/auth/logout'),
me: () => api.get<MeResult>('/auth/me'),
me: () => api.get<MeResult>('/auth/me'),
};
+7 -7
View File
@@ -6,11 +6,11 @@
let accessToken: string | null = null;
export const authToken = {
get: () => accessToken,
set: (token: string) => {
accessToken = token;
},
clear: () => {
accessToken = null;
},
get: () => accessToken,
set: (token: string) => {
accessToken = token;
},
clear: () => {
accessToken = null;
},
};