初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 deletions
@@ -0,0 +1,276 @@
export interface PublicKeyResponse {
enabled: boolean
scope: string
algorithm: string
key_version: string
public_key: string
encrypted_paths?: string[]
}
export interface EncryptSession {
aesKey: Uint8Array
iv: Uint8Array
keyVersion: string
encryptedKey: string
encryptedIv: string
}
interface EncryptRequestResult {
body: string
headers: Record<string, string>
session: EncryptSession | null
}
const PUBLIC_KEY_PATH = '/system/security/api-encrypt/public-key'
let publicKeyCache: PublicKeyResponse | null = null
let publicKeyFailureAt = 0
const PUBLIC_KEY_RETRY_INTERVAL = 30000
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
function normalizeBaseUrl(baseURL: string): string {
return baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
}
function normalizePath(path: string): string {
return path.startsWith('/') ? path : `/${path}`
}
function resolveRequestPath(baseURL: string, path: string): string {
const normalizedBaseUrl = normalizeBaseUrl(baseURL)
const normalizedPath = normalizePath(path)
if (/^https?:\/\//i.test(normalizedPath)) {
const url = new URL(normalizedPath)
return url.pathname
}
if (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/adminapi/')) {
return normalizedPath
}
try {
const url = new URL(`${normalizedBaseUrl}${normalizedPath}`, window.location.origin)
return url.pathname
} catch {
return normalizedPath
}
}
function shouldSkipEncrypt(path: string): boolean {
return path === '/api/auth/refresh'
|| path === '/adminapi/auth/refresh'
|| path === '/api/system/security/api-encrypt/public-key'
|| path === '/adminapi/system/security/api-encrypt/public-key'
}
function matchesEncryptedPath(path: string, encryptedPaths: string[] = []): boolean {
const normalizedPath = normalizePath(path)
return encryptedPaths.some((encryptedPath) => {
const normalizedEncryptedPath = normalizePath(encryptedPath)
return normalizedEncryptedPath === normalizedPath || normalizedPath.startsWith(`${normalizedEncryptedPath}/`)
})
}
function toBase64(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
let binary = ''
bytes.forEach((byte) => {
binary += String.fromCharCode(byte)
})
return btoa(binary)
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
function fromPem(pem: string): ArrayBuffer {
const clean = pem.replace(/-----BEGIN PUBLIC KEY-----/g, '')
.replace(/-----END PUBLIC KEY-----/g, '')
.replace(/\s+/g, '')
const binary = atob(clean)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
async function importPublicKey(publicKeyPem: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'spki',
fromPem(publicKeyPem),
{
name: 'RSA-OAEP',
hash: 'SHA-256'
},
true,
['encrypt']
)
}
async function rsaEncrypt(publicKeyPem: string, data: Uint8Array): Promise<string> {
const publicKey = await importPublicKey(publicKeyPem)
const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, data)
return toBase64(encrypted)
}
async function aesEncrypt(plainText: string, aesKey: Uint8Array, iv: Uint8Array): Promise<string> {
const cryptoKey = await crypto.subtle.importKey(
'raw',
toArrayBuffer(aesKey),
{ name: 'AES-CBC' },
false,
['encrypt']
)
const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, textEncoder.encode(plainText))
return toBase64(encrypted)
}
async function aesDecrypt(cipherText: string, aesKey: Uint8Array, iv: Uint8Array): Promise<string> {
const cryptoKey = await crypto.subtle.importKey(
'raw',
toArrayBuffer(aesKey),
{ name: 'AES-CBC' },
false,
['decrypt']
)
const binary = atob(cipherText)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, toArrayBuffer(bytes))
return textDecoder.decode(decrypted)
}
async function fetchPublicKey(baseURL: string): Promise<PublicKeyResponse | null> {
const requestUrl = `${normalizeBaseUrl(baseURL)}${PUBLIC_KEY_PATH}`
const response = await fetch(requestUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const result = await response.json()
if (!result || result.code !== 0 || !result.data) {
return null
}
return result.data as PublicKeyResponse
}
async function ensurePublicKey(baseURL: string): Promise<PublicKeyResponse | null> {
if (publicKeyCache) {
return publicKeyCache
}
if (publicKeyFailureAt > 0 && Date.now() - publicKeyFailureAt < PUBLIC_KEY_RETRY_INTERVAL) {
return null
}
let data: PublicKeyResponse | null = null
try {
data = await fetchPublicKey(baseURL)
} catch {
publicKeyFailureAt = Date.now()
return null
}
if (!data) {
publicKeyFailureAt = Date.now()
return null
}
publicKeyFailureAt = 0
publicKeyCache = data
return publicKeyCache
}
export function clearApiEncryptPublicKeyCache() {
publicKeyCache = null
publicKeyFailureAt = 0
}
export async function prepareEncryptedRequest(baseURL: string, path: string, method: string, body?: unknown): Promise<EncryptRequestResult> {
const resolvedPath = resolveRequestPath(baseURL, path)
if (!resolvedPath.startsWith('/api/') && !resolvedPath.startsWith('/adminapi/')) {
return {
body: body ? JSON.stringify(body) : '',
headers: {},
session: null
}
}
if (shouldSkipEncrypt(resolvedPath)) {
return {
body: body ? JSON.stringify(body) : '',
headers: {},
session: null
}
}
const publicKeyData = await ensurePublicKey(baseURL)
if (!publicKeyData || !publicKeyData.enabled || publicKeyData.scope === 'off') {
return {
body: body ? JSON.stringify(body) : '',
headers: {},
session: null
}
}
if (publicKeyData.scope === 'partial' && !matchesEncryptedPath(resolvedPath, publicKeyData.encrypted_paths || [])) {
return {
body: body ? JSON.stringify(body) : '',
headers: {},
session: null
}
}
const aesKey = crypto.getRandomValues(new Uint8Array(32))
const iv = crypto.getRandomValues(new Uint8Array(16))
const encryptedKey = await rsaEncrypt(publicKeyData.public_key, aesKey)
const encryptedIv = await rsaEncrypt(publicKeyData.public_key, iv)
let encryptedBody = ''
if (body !== undefined && !['GET', 'HEAD'].includes(method.toUpperCase())) {
const payload = JSON.stringify(body)
encryptedBody = await aesEncrypt(payload, aesKey, iv)
}
return {
body: encryptedBody === '' ? '' : JSON.stringify({
encrypted: true,
algorithm: publicKeyData.algorithm,
key_version: publicKeyData.key_version,
data: encryptedBody,
method
}),
headers: {
'X-Api-Encrypt': '1',
'X-Api-Encrypt-Version': publicKeyData.key_version,
'X-Api-Encrypt-Key': encryptedKey,
'X-Api-Encrypt-Iv': encryptedIv
},
session: {
aesKey,
iv,
keyVersion: publicKeyData.key_version,
encryptedKey,
encryptedIv
}
}
}
export async function decryptEncryptedResponse(data: any, session: EncryptSession | null): Promise<any> {
if (!data || data.encrypted !== true || !session) {
return data
}
const plainText = await aesDecrypt(String(data.data || ''), session.aesKey, session.iv)
return JSON.parse(plainText)
}
+182
View File
@@ -0,0 +1,182 @@
/**
* HTTP 错误处理模块
*
* 提供统一的 HTTP 请求错误处理机制
*
* ## 主要功能
*
* - 自定义 HttpError 错误类,封装错误信息、状态码、时间戳等
* - 错误拦截和转换,将 Axios 错误转换为标准的 HttpError
* - 错误消息国际化处理,根据状态码返回对应的多语言错误提示
* - 错误日志记录,便于问题追踪和调试
* - 错误和成功消息的统一展示
* - 类型守卫函数,用于判断错误类型
*
* ## 使用场景
*
* - HTTP 请求拦截器中统一处理错误
* - 业务代码中捕获和处理特定错误
* - 错误日志收集和上报
*
* @module utils/http/error
* @author Art Design Pro Team
*/
import { AxiosError } from 'axios'
import { ApiStatus } from './status'
import { $t } from '@/locales'
// 错误响应接口
export interface ErrorResponse {
/** 错误状态码 */
code: number
/** 错误消息 */
msg: string
/** 错误附加数据 */
data?: unknown
}
// 错误日志数据接口
export interface ErrorLogData {
/** 错误状态码 */
code: number
/** 错误消息 */
message: string
/** 错误附加数据 */
data?: unknown
/** 错误发生时间戳 */
timestamp: string
/** 请求 URL */
url?: string
/** 请求方法 */
method?: string
/** 错误堆栈信息 */
stack?: string
}
// 自定义 HttpError 类
export class HttpError extends Error {
public readonly code: number
public readonly data?: unknown
public readonly timestamp: string
public readonly url?: string
public readonly method?: string
constructor(
message: string,
code: number,
options?: {
data?: unknown
url?: string
method?: string
}
) {
super(message)
this.name = 'HttpError'
this.code = code
this.data = options?.data
this.timestamp = new Date().toISOString()
this.url = options?.url
this.method = options?.method
}
public toLogData(): ErrorLogData {
return {
code: this.code,
message: this.message,
data: this.data,
timestamp: this.timestamp,
url: this.url,
method: this.method,
stack: this.stack
}
}
}
/**
* 获取错误消息
* @param status 错误状态码
* @returns 错误消息
*/
const getErrorMessage = (status: number): string => {
const errorMap: Record<number, string> = {
[ApiStatus.unauthorized]: 'httpMsg.unauthorized',
[ApiStatus.forbidden]: 'httpMsg.forbidden',
[ApiStatus.notFound]: 'httpMsg.notFound',
[ApiStatus.methodNotAllowed]: 'httpMsg.methodNotAllowed',
[ApiStatus.requestTimeout]: 'httpMsg.requestTimeout',
[ApiStatus.internalServerError]: 'httpMsg.internalServerError',
[ApiStatus.badGateway]: 'httpMsg.badGateway',
[ApiStatus.serviceUnavailable]: 'httpMsg.serviceUnavailable',
[ApiStatus.gatewayTimeout]: 'httpMsg.gatewayTimeout'
}
return $t(errorMap[status] || 'httpMsg.internalServerError')
}
/**
* 处理错误
* @param error 错误对象
* @returns 错误对象
*/
export function handleError(error: AxiosError<ErrorResponse>): never {
// 处理取消的请求
if (error.code === 'ERR_CANCELED') {
console.warn('Request cancelled:', error.message)
throw new HttpError($t('httpMsg.requestCancelled'), ApiStatus.error)
}
const statusCode = error.response?.status
const errorMessage = error.response?.data?.msg || error.message
const requestConfig = error.config
// 处理网络错误
if (!error.response) {
throw new HttpError($t('httpMsg.networkError'), ApiStatus.error, {
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase()
})
}
// 处理 HTTP 状态码错误
const message = statusCode
? getErrorMessage(statusCode)
: errorMessage || $t('httpMsg.requestFailed')
throw new HttpError(message, statusCode || ApiStatus.error, {
data: error.response.data,
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase()
})
}
/**
* 显示错误消息
* @param error 错误对象
* @param showMessage 是否显示错误消息
*/
export function showError(error: HttpError, showMessage: boolean = true): void {
if (showMessage) {
ElMessage.error(error.message)
}
// 记录错误日志
console.error('[HTTP Error]', error.toLogData())
}
/**
* 显示成功消息
* @param message 成功消息
* @param showMessage 是否显示消息
*/
export function showSuccess(message: string, showMessage: boolean = true): void {
if (showMessage) {
ElMessage.success(message)
}
}
/**
* 判断是否为 HttpError 类型
* @param error 错误对象
* @returns 是否为 HttpError 类型
*/
export const isHttpError = (error: unknown): error is HttpError => {
return error instanceof HttpError
}
+578
View File
@@ -0,0 +1,578 @@
/**
* HTTP 请求封装模块
* 基于 Axios 封装的 HTTP 请求工具,提供统一的请求/响应处理
*
* ## 主要功能
*
* - 请求/响应拦截器(自动添加 Token、统一错误处理)
* - 401 未授权自动登出(带防抖机制)
* - 请求失败自动重试(可配置)
* - 统一的成功/错误消息提示
* - 支持 GET/POST/PUT/DELETE 等常用方法
*
* @module utils/http
* @author Art Design Pro Team
*/
import axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/store/modules/user'
import { ApiStatus } from './status'
import { HttpError, handleError, showError, showSuccess } from './error'
import { $t } from '@/locales'
import { BaseResponse } from '@/types'
import { AuthService } from '@/api/system/auth/auth'
import { decryptEncryptedResponse, EncryptSession, prepareEncryptedRequest } from './api-encrypt'
/** 请求配置常量 */
const REQUEST_TIMEOUT = 15000
const LOGOUT_DELAY = 500
const MAX_RETRIES = 0
const RETRY_DELAY = 1000
/** Token刷新配置(从环境变量读取,可通过 setTokenRefreshEnabled 动态修改) */
let tokenRefreshEnabled = true // 初始化为 true,后面会被环境变量覆盖
const REFRESH_TIMEOUT = 10000 // 刷新超时 10 秒
/** Token刷新相关状态 */
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []
let refreshFailed = false
let refreshStartTime = 0
/**
* 设置 Token 刷新开关
* @param enabled 是否启用 token 刷新
*/
export function setTokenRefreshEnabled(enabled: boolean) {
tokenRefreshEnabled = enabled
}
/**
* 获取 Token 刷新开关状态
*/
export function isTokenRefreshEnabled() {
return tokenRefreshEnabled
}
/**
* 重置 Token 刷新状态(登录成功后调用)
*/
export function resetTokenRefreshState() {
refreshFailed = false
resetRefreshState()
}
/** 扩展 AxiosRequestConfig */
interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
showErrorMessage?: boolean
showSuccessMessage?: boolean
_encryptSession?: EncryptSession | null
}
const { VITE_API_URL, VITE_WITH_CREDENTIALS, VITE_TOKEN_REFRESH } = import.meta.env
// 初始化 Token 刷新配置
tokenRefreshEnabled = VITE_TOKEN_REFRESH !== 'false'
/** Axios实例 */
const axiosInstance = axios.create({
timeout: REQUEST_TIMEOUT,
baseURL: VITE_API_URL,
withCredentials: VITE_WITH_CREDENTIALS === 'true',
validateStatus: (status) => status >= 200 && status < 300,
transformResponse: [
(data, headers) => {
const contentType = headers['content-type']
if (contentType?.includes('application/json')) {
try {
return JSON.parse(data)
} catch {
return data
}
}
return data
}
]
})
/** 请求拦截器 */
axiosInstance.interceptors.request.use(
async (request: InternalAxiosRequestConfig) => {
const { accessToken } = useUserStore()
if (accessToken) request.headers.set('Authorization', accessToken)
const requestUrl = String(request.url || '')
const requestMethod = String(request.method || 'GET').toUpperCase()
const isFormData = request.data instanceof FormData
const isBlobResponse = request.responseType === 'blob'
if (!isFormData && !isBlobResponse) {
if (!request.headers['Content-Type']) {
request.headers.set('Content-Type', 'application/json')
}
const requestData = typeof request.data === 'string' ? undefined : request.data
const encryptedRequest = await prepareEncryptedRequest(
String(request.baseURL || VITE_API_URL || ''),
requestUrl,
requestMethod,
requestData
)
Object.entries(encryptedRequest.headers).forEach(([key, value]) => {
request.headers.set(key, value)
})
;(request as ExtendedAxiosRequestConfig)._encryptSession = encryptedRequest.session
if (encryptedRequest.body) {
request.data = encryptedRequest.body
} else if (requestData && typeof request.data !== 'string') {
request.data = JSON.stringify(request.data)
}
} else if (request.data && !isFormData && !request.headers['Content-Type'] && typeof request.data !== 'string') {
request.headers.set('Content-Type', 'application/json')
request.data = JSON.stringify(request.data)
}
return request
},
(error) => {
showError(createHttpError($t('httpMsg.requestConfigError'), ApiStatus.error))
return Promise.reject(error)
}
)
/** 响应拦截器 */
axiosInstance.interceptors.response.use(
async (response: AxiosResponse<BaseResponse>) => {
if (response.config.responseType === 'blob') return response
response.data = await decryptEncryptedResponse(
response.data,
(response.config as ExtendedAxiosRequestConfig)._encryptSession || null
)
const { code, msg } = response.data
if (code === ApiStatus.success) return response
// 业务层 401/402 由下方的 error 处理
throw createHttpError(msg || $t('httpMsg.requestFailed'), code)
},
async (error) => {
const originalRequest = error.config
const status = error.response?.status
let responseData = error.response?.data
if (error.response?.config?.responseType !== 'blob') {
responseData = await decryptEncryptedResponse(
responseData,
(error.response?.config as ExtendedAxiosRequestConfig)?._encryptSession || null
)
if (error.response) {
error.response.data = responseData
}
}
// 判断是否是 401/402:HTTP状态码 或 业务code
const isAuthError = status === 401 || status === 402 ||
responseData?.code === 401 || responseData?.code === 402
// 处理 401/402 错误
if (isAuthError && originalRequest) {
const userStore = useUserStore()
const hasToken = userStore.hasRefreshToken()
// 如果未启用 token 刷新,直接退出
if (!tokenRefreshEnabled) {
logOut()
return Promise.reject(error)
}
// 如果已有刷新失败标记,直接退出
if (refreshFailed) {
return Promise.reject(error)
}
// 正在刷新中,检查是否超时
if (isRefreshing) {
const elapsed = Date.now() - refreshStartTime
if (elapsed > REFRESH_TIMEOUT) {
// 刷新超时,重置状态,允许新的刷新
resetRefreshState()
} else {
// 还在刷新中,加入队列等待
return new Promise((resolve, reject) => {
addRefreshSubscriber((token: string) => {
if (token) {
originalRequest.headers['Authorization'] = `Bearer ${token}`
resolve(axiosInstance(originalRequest))
} else {
reject(error)
}
})
})
}
}
// 如果有 refresh token,尝试刷新
if (hasToken) {
isRefreshing = true
refreshStartTime = Date.now()
try {
const newAccessToken = await refreshAccessToken()
// 更新请求头并重试
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`
// 通知队列中的请求并重试
executeRefreshSubscribers(newAccessToken)
// 重置刷新状态
resetRefreshState()
return axiosInstance(originalRequest)
} catch (e) {
// 刷新失败,通知队列
executeRefreshSubscribers('')
resetRefreshState()
refreshFailed = true
logOut()
return Promise.reject(error)
}
}
// 没有 refresh token,直接退出
logOut()
return Promise.reject(error)
}
return Promise.reject(handleError(error))
}
)
/** 统一创建HttpError */
function createHttpError(message: string, code: number) {
return new HttpError(message, code)
}
/** 添加刷新订阅者 */
function addRefreshSubscriber(callback: (token: string) => void) {
refreshSubscribers.push(callback)
}
/** 执行刷新订阅者 */
function executeRefreshSubscribers(token: string) {
refreshSubscribers.forEach(callback => callback(token))
refreshSubscribers = []
}
/** 重置刷新状态 */
function resetRefreshState() {
isRefreshing = false
refreshSubscribers = []
refreshFailed = false
}
/** 刷新token */
async function refreshAccessToken(): Promise<string> {
const userStore = useUserStore()
if (!userStore.hasRefreshToken()) {
throw new HttpError('没有可用的刷新令牌', ApiStatus.unauthorized)
}
try {
// 调用刷新token接口
const response = await AuthService.refreshAccessToken(userStore.refreshToken) as AxiosResponse
const data = response.data
// 检查响应结构
if (!data || typeof data.code === 'undefined') {
throw new HttpError('刷新响应格式错误', ApiStatus.error)
}
if (data.code !== ApiStatus.success) {
if (data.code === 402) {
throw new HttpError('登录已过期,请重新登录', 402)
}
throw new HttpError(data.msg || '刷新令牌失败', data.code || ApiStatus.unauthorized)
}
const tokenData = data.data
if (!tokenData || !tokenData.access_token) {
throw new HttpError('刷新响应数据错误', ApiStatus.error)
}
const newAccessToken = tokenData.access_token
const newRefreshToken = tokenData.refresh_token
const newExpiresAt = tokenData.expires_at || (Math.floor(Date.now() / 1000) + (tokenData.expires_in || 7200))
// 更新store中的token
userStore.updateAccessToken(newAccessToken)
// 如果有新的refresh token,也更新
if (newRefreshToken) {
userStore.updateRefreshToken(newRefreshToken)
}
// 更新过期时间
userStore.expiresAt = newExpiresAt
return newAccessToken
} catch (e) {
// 如果是 HttpError,直接抛出
if (e instanceof HttpError) {
throw e
}
// 其他错误(如网络错误)
throw new HttpError('刷新令牌失败,请检查网络', ApiStatus.error)
}
}
/** 退出登录函数 */
function logOut() {
resetRefreshState()
setTimeout(() => {
useUserStore().logOut()
}, LOGOUT_DELAY)
}
/** 是否需要重试 */
function shouldRetry(statusCode: number) {
return [
ApiStatus.requestTimeout,
ApiStatus.internalServerError,
ApiStatus.badGateway,
ApiStatus.serviceUnavailable,
ApiStatus.gatewayTimeout
].includes(statusCode)
}
/** 请求重试逻辑 */
async function retryRequest<T>(
config: ExtendedAxiosRequestConfig,
retries: number = MAX_RETRIES
): Promise<T> {
try {
return await request<T>(config)
} catch (error) {
if (retries > 0 && error instanceof HttpError && shouldRetry(error.code)) {
await delay(RETRY_DELAY)
return retryRequest<T>(config, retries - 1)
}
throw error
}
}
/** 延迟函数 */
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/** 请求函数 */
async function request<T = any>(config: ExtendedAxiosRequestConfig): Promise<T> {
// POST | PUT 参数自动填充
if (
['POST', 'PUT'].includes(config.method?.toUpperCase() || '') &&
config.params &&
!config.data
) {
config.data = config.params
config.params = undefined
}
try {
const res = await axiosInstance.request<BaseResponse<T>>(config)
// 显示成功消息
if (config.showSuccessMessage && res.data.msg) {
showSuccess(res.data.msg)
}
if (config.responseType === 'blob') return res.data as T
return res.data.data as T
} catch (error) {
if (error instanceof HttpError && error.code !== ApiStatus.unauthorized) {
const showMsg = config.showErrorMessage !== false
showError(error, showMsg)
}
return Promise.reject(error)
}
}
/** API方法集合 */
const api = {
get<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'GET' })
},
post<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'POST' })
},
put<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'PUT' })
},
patch<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'PATCH' })
},
del<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'DELETE' })
},
request<T>(config: ExtendedAxiosRequestConfig): Promise<T> {
return retryRequest<T>({ ...config })
},
upload<T = any>(
url: string,
data: Record<string, any> & { file: Blob | File },
config?: ExtendedAxiosRequestConfig
): Promise<T> {
const formData = new FormData()
Object.entries(data).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item, index) => {
formData.append(`${key}[${index}]`, item)
})
} else {
formData.append(key, value)
}
})
const finalConfig: ExtendedAxiosRequestConfig = {
...config,
headers: {
'Content-Type': 'multipart/form-data',
...config?.headers
}
}
return retryRequest({
url: url,
data: formData,
method: 'POST',
...finalConfig
})
},
/**
* SSE (Server-Sent Events) 请求方法
* @param config 请求配置
* @param eventHandlers 事件处理函数
* @returns SSE连接对象,包含关闭方法
*/
sse(
config: ExtendedAxiosRequestConfig,
eventHandlers: {
message?: (data: any) => void;
error?: (error: Event) => void;
open?: () => void;
[eventName: string]: ((data: any) => void) | undefined;
}
) {
const { url, params } = config
if (!url) {
throw new Error('SSE request requires a URL')
}
// 构建完整URL,包括baseURL
let fullUrl = url
// 检查是否为绝对路径
if (!/^https?:\/\//i.test(url)) {
// 使用VITE_API_URL作为基础URL
fullUrl = `${VITE_API_URL}${url.startsWith('/') ? '' : '/'}${url}`
}
// 添加查询参数
if (params) {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value))
}
})
const queryString = searchParams.toString()
if (queryString) {
fullUrl += (fullUrl.includes('?') ? '&' : '?') + queryString
}
}
// 添加认证token(注意:EventSource不支持自定义headers,这里使用URL参数传递token作为备选方案)
// 如果需要在headers中传递token,可能需要使用Fetch API模拟SSE
const { accessToken } = useUserStore()
if (accessToken && !fullUrl.includes('token=')) {
// 检查accessToken是否已经包含Bearer前缀
let bearerToken = accessToken
if (!bearerToken.startsWith('Bearer ')) {
// 后端期望 Bearer xxx 格式,需要添加 Bearer 前缀
bearerToken = `Bearer ${bearerToken}`
}
fullUrl += (fullUrl.includes('?') ? '&' : '?') + `token=${encodeURIComponent(bearerToken)}`
}
// 创建EventSource
const eventSource = new EventSource(fullUrl, {
withCredentials: VITE_WITH_CREDENTIALS === 'true'
})
// 注册事件处理器
if (eventHandlers.message) {
eventSource.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data)
eventHandlers.message?.(data)
} catch (error) {
eventHandlers.message?.(event.data)
}
})
}
// 打开事件处理
eventSource.addEventListener('open', () => {
console.log('SSE连接已打开')
// 调用用户提供的打开处理函数
if (eventHandlers.open) {
eventHandlers.open()
}
})
// 注册自定义事件处理器(包括服务器发送的 error 事件)
Object.entries(eventHandlers).forEach(([eventName, handler]) => {
if (eventName !== 'message' && eventName !== 'connectionError' && eventName !== 'open' && typeof handler === 'function') {
eventSource.addEventListener(eventName, (event) => {
try {
const data = JSON.parse(event.data)
handler(data)
} catch (error) {
handler(event.data)
}
})
}
})
// 处理 EventSource 连接错误
eventSource.addEventListener('error', (event) => {
console.log('SSE connection error:', event)
console.log('SSE readyState:', eventSource.readyState)
// 如果 readyState 为 2 (CLOSED),说明连接已关闭
if (eventSource.readyState === 2) {
// 调用用户提供的连接错误处理函数
if (eventHandlers.connectionError) {
eventHandlers.connectionError(event)
}
}
})
// 返回SSE连接对象
return {
eventSource,
close: () => {
console.log('SSE连接手动关闭')
eventSource.close()
},
readyState: () => eventSource.readyState
}
}
}
export default api
+18
View File
@@ -0,0 +1,18 @@
/**
* 接口状态码
*/
export enum ApiStatus {
success = 0, // 成功
error = 400, // 错误
unauthorized = 401, // 未授权
forbidden = 403, // 禁止访问
notFound = 404, // 未找到
methodNotAllowed = 405, // 方法不允许
requestTimeout = 408, // 请求超时
internalServerError = 500, // 服务器错误
notImplemented = 501, // 未实现
badGateway = 502, // 网关错误
serviceUnavailable = 503, // 服务不可用
gatewayTimeout = 504, // 网关超时
httpVersionNotSupported = 505 // HTTP版本不支持
}