Files
ele-HeiXiu/src/services/request.ts
YoungestSongMo a8ace232a4 feat: 任务列表UI重构 + 分页器/登录错误/请求拦截修复
【任务列表 UI 重构】(TaskHistory.tsx)
  - 列重新排序:任务ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 → 时长 → 费用 → 供应商(隐藏) → 帧率/文件大小/标签/错误信息(隐藏) → 用户
  - 斑马纹行样式 + hover 增强 + 选中行高亮 + 暗色主题适配
  - 列可见性选择器(齿轮图标 Popover),持久化到 localStorage
  - 视频时长通过 VideoUiParams.duration 计算,非视频显示 "-"
  - 未知状态降级处理(resolveStatus → 显示原始 key)
  - 分页器固定预留 48px,避免 >20 条时分页器溢出可视区
  - 提示词搜索图标颜色用 CSS class 承载,避免 columns 依赖 token.colorPrimary 导致主题切换时 Table 重建

  【分页器修复】(antd-theme.ts)
  - Pagination 组件 token 添加 itemActiveColor: '#fff'
  - 激活页码数字与 #6366F1 主题背景形成对比,亮色/暗色均可见
  - 替代之前的 CSS !important hack

  【登录错误信息修复】(request.ts)
  - HTTP 401 拦截改为双重校验:status === 401 && data?.code === 401
  - 仅业务码也为 401 时才走 token 刷新流程
  - 其他业务码(如密码错误)透传后端 data.message,不再被 RefreshError 覆盖

  【全局中文 locale】(ThemeProvider.tsx)
  - ConfigProvider 添加 locale={zhCN},分页器显示"条/页"等中文
  - 避免逐个 Table 设置不完整 locale 导致 Pagination Select 退化为 Input

  【上下文稳定性】(HomeContent.tsx)
  - contextValue 用 useMemo 包裹,避免每次 render 新建对象导致无关重渲染
  - 配合 columns 依赖清理,减少主题切换时的级联更新

  【其他】
  - index.html CSP 策略增加 COS 域名白名单
  - OutputTypeMap 枚举值修复为 image='图片', video='视频', audio='音频'
  - TaskStatusMap STATUS_CONFIG 类型放宽为 Record<string,...> 兼容未知状态
  - 移除 computeDuration,替换为 getVideoDuration + formatDuration
  - LoginPage 移除未使用的 authState 依赖
2026-06-05 20:49:23 +08:00

424 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// Axios 请求封装
//
// 功能:
// - 统一 BaseURL环境变量 VITE_API_BASE_URL
// - 请求拦截器:自动附加 Token
// - 响应拦截器:统一错误处理、业务码校验
// - 类型安全的 GET / POST / PUT / DELETE / UPLOAD
// - 请求超时、取消请求支持
// ============================================================
import axios, {
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios';
import type { ApiResponse } from './types';
import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types';
import { emit, EVENTS } from '../utils/event-bus';
import { logger } from '../utils/logger';
import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '../utils/safe-storage';
// ---------- 配置 ----------
/** API 基础地址(可通过 Vite 环境变量覆盖) */
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
/** 请求超时时间(毫秒) */
const TIMEOUT = 30_000;
/** localStorage 中 Token 的键名(密文由 safeStorage 保护) */
const TOKEN_KEY = 'ele-heixiu-token';
/** refresh token 接口路径(该接口 401 时不应触发 handle401否则死循环 */
const REFRESH_TOKEN_URL = '/api/v1/auth/refresh';
/** 判断请求是否为 refresh token 接口 */
function isRefreshRequest(config: InternalAxiosRequestConfig): boolean {
return config.url?.includes(REFRESH_TOKEN_URL) ?? false;
}
// ---------- Token 管理 ----------
/**
* 启动时初始化 Token从加密存储解密到内存缓存。
* 应在 AppProvider 挂载时调用login/logout 之前)。
*/
export async function initTokenStore(): Promise<void> {
await initSafeStore(TOKEN_KEY);
}
/** 获取当前内存缓存中的 Token同步高频安全 */
export function getToken(): string | null {
return getSafeStore(TOKEN_KEY);
}
/** 设置 Token异步加密存储 + 更新内存缓存) */
export async function setToken(token: string): Promise<void> {
await setSafeStore(TOKEN_KEY, token);
}
/** 清除 Token同步清除内存缓存 + localStorage */
export function clearToken(): void {
clearSafeStore(TOKEN_KEY);
}
// ---------- Token 刷新由外部注册request.ts 不依赖任何 auth 模块) ----------
let refreshHandler: (() => Promise<string | null>) | null = null;
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
/** 等待刷新完成的请求队列 */
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
/** 由 auth 模块调用,注册 token 刷新回调 */
export function setTokenRefreshHandler(handler: () => Promise<string | null>): void {
refreshHandler = handler;
}
function processQueue(error: Error | null, token: string | null): void {
failedQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token!);
});
failedQueue = [];
}
// ---------- Axios 实例 ----------
const instance: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
// ---------- 请求拦截器 ----------
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 自动附加 Token
const token = getToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
() => {
return Promise.reject(new RequestError(RequestErrorType.NETWORK, '请求配置失败'));
},
);
// ---------- 401 → 刷新 Token + 重试 ----------
function handle401(config: InternalAxiosRequestConfig): Promise<AxiosResponse<ApiResponse>> {
// 已经在刷新中 → 排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(instance(config));
},
reject,
});
});
}
// 没有注册刷新回调 → 直接要求重新登录
if (!refreshHandler) {
clearToken();
emit(EVENTS.AUTH_REQUIRED);
return Promise.reject(new RefreshError());
}
// 开始刷新
isRefreshing = true;
refreshPromise = refreshHandler();
return new Promise((resolve, reject) => {
refreshPromise!
.then((newToken) => {
if (newToken) {
processQueue(null, newToken);
config.headers.Authorization = `Bearer ${newToken}`;
resolve(instance(config));
} else {
const err = new RefreshError();
processQueue(err, null);
clearToken();
emit(EVENTS.AUTH_REQUIRED);
reject(err);
}
isRefreshing = false;
refreshPromise = null;
})
.catch((err) => {
processQueue(err, null);
isRefreshing = false;
refreshPromise = null;
clearToken();
emit(EVENTS.AUTH_REQUIRED);
reject(new RefreshError());
});
});
}
// ---------- 响应拦截器 ----------
instance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { data } = response;
// HTTP 200 但业务码非 0 → 业务错误
if (data && data.code !== 0) {
// 业务码 401 → 尝试刷新 token 并重试refresh 接口自身 401 直接拒绝)
if (data.code === 401 && response.config) {
if (isRefreshRequest(response.config)) {
return Promise.reject(new RefreshError());
}
return handle401(response.config);
}
logger.warn('request', `Business error: ${data.message || '请求失败'}`, undefined, {
url: response.config.url,
method: response.config.method?.toUpperCase(),
code: data.code,
traceId: data.traceId,
});
return Promise.reject(
new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', {
code: data.code,
traceId: data.traceId,
}),
);
}
return response;
},
(error) => {
// 提取请求上下文(所有日志共用)
const reqCtx = {
url: error.config?.url,
method: error.config?.method?.toUpperCase(),
};
// 全局捕获 RefreshError → 自动触发登录 Modal
if (isRefreshError(error)) {
logger.debug('request', 'Auth expired (RefreshError caught)', {
...reqCtx,
errorType: error.name,
errorMessage: error.message,
});
clearToken();
emit(EVENTS.AUTH_REQUIRED);
return Promise.reject(error);
}
// 请求已被取消
if (axios.isCancel(error)) {
const cancelErr = new RequestError(RequestErrorType.CANCELLED, '请求已取消');
logger.debug('request', 'Request cancelled', reqCtx);
return Promise.reject(cancelErr);
}
// 超时
if (error.code === 'ECONNABORTED') {
const timeoutErr = new RequestError(RequestErrorType.TIMEOUT, '请求超时,请检查网络');
logger.warn('request', 'Request timeout', timeoutErr, reqCtx);
return Promise.reject(timeoutErr);
}
// 无网络
if (!error.response) {
const netErr = new RequestError(RequestErrorType.NETWORK, '网络异常,请检查连接');
logger.warn('request', 'Network error', netErr, reqCtx);
return Promise.reject(netErr);
}
// HTTP 状态码错误
const { status, data } = error.response;
// 401 → 尝试刷新 token 并重试
// ⚠️ 同时检查响应体中的业务码 data.code
// data.code === 401 → token 过期 → 走刷新流程
// data.code !== 401 → 其他认证错误(如密码错误)→ 透传后端 message
if (status === 401 && error.config && data?.code === 401) {
if (isRefreshRequest(error.config)) {
const refreshErr = new RefreshError();
logger.debug('request', 'Refresh endpoint returned 401', {
...reqCtx,
errorType: refreshErr.name,
});
return Promise.reject(refreshErr);
}
return handle401(error.config);
}
let errMsg = '请求失败';
switch (status) {
case 400:
errMsg = '请求参数有误';
break;
case 403:
errMsg = '没有访问权限';
break;
case 404:
errMsg = '请求的资源不存在';
break;
case 500:
errMsg = '服务器内部错误';
break;
case 502:
errMsg = '网关错误';
break;
case 503:
errMsg = '服务暂时不可用';
break;
}
const httpErr = new RequestError(RequestErrorType.HTTP, data?.message || errMsg, {
httpStatus: status,
code: data?.code,
});
// HTTP 5xx → error 级别4xx → warn 级别
if (status >= 500) {
logger.error('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx);
} else {
logger.warn('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx);
}
return Promise.reject(httpErr);
},
);
// ---------- 请求方法 ----------
/**
* 发起 GET 请求
* @param url - 请求路径
* @param params - 查询参数
* @param config - Axios 配置覆盖
*/
export async function get<T>(
url: string,
params?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<T> {
const res = await instance.get<ApiResponse<T>>(url, { ...config, params });
return res.data.data;
}
/**
* 发起 POST 请求JSON
* @param url - 请求路径
* @param data - 请求体
* @param config - Axios 配置覆盖
*/
export async function post<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<T> {
const res = await instance.post<ApiResponse<T>>(url, data, config);
return res.data.data;
}
/**
* 发起 PUT 请求JSON
*/
export async function put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const res = await instance.put<ApiResponse<T>>(url, data, config);
return res.data.data;
}
/**
* 发起 DELETE 请求
*/
export async function del<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const res = await instance.delete<ApiResponse<T>>(url, config);
return res.data.data;
}
/**
* 文件上传multipart/form-data
* @param url - 上传接口路径
* @param formData - FormData 对象
* @param onProgress - 上传进度回调 (0~100)
*/
export async function upload<T>(
url: string,
formData: FormData,
onProgress?: (percent: number) => void,
): Promise<T> {
const res = await instance.post<ApiResponse<T>>(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000, // 上传超时 2 分钟
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percent);
}
},
});
return res.data.data;
}
/**
* 文件下载
* @param url - 下载接口路径
* @param filename - 保存的文件名
*/
export async function download(url: string, filename?: string): Promise<void> {
const res = await instance.get(url, {
responseType: 'blob',
});
const blob = new Blob([res.data]);
const downloadUrl = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = downloadUrl;
anchor.download = filename || 'download';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.URL.revokeObjectURL(downloadUrl);
}
/**
* 创建可取消的请求控制器
* 用法:
* const { controller, request } = createCancellableRequest();
* request(get('/some/url')); // 发起请求
* controller.abort(); // 取消请求
*/
export function createCancellableRequest() {
const controller = new AbortController();
return {
controller,
signal: controller.signal,
/** 包裹一个请求 Promise使其可通过 controller.abort() 取消 */
request: <T>(promise: Promise<T>): Promise<T> => {
return new Promise<T>((resolve, reject) => {
promise.then(resolve).catch(reject);
controller.signal.addEventListener('abort', () => {
reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
});
});
},
};
}
// ---------- 导出 axios 实例(供高级场景直接使用) ----------
export { instance as axiosInstance };
export { RequestError, RequestErrorType };