This commit is contained in:
2025-12-26 16:45:58 +08:00
commit 1a20560753
190 changed files with 37841 additions and 0 deletions

377
src/api/core/download.ts Normal file
View File

@ -0,0 +1,377 @@
import { httpClient } from './request'
import { DownloadConfig, ApiResponse } from '../types'
import { API_ENDPOINTS } from '../config'
import { downloadFile, generateId } from '../utils'
/**
* 下载响应类型
*/
export interface DownloadResponse {
blob: Blob
filename: string
size: number
type: string
}
/**
* 批量下载响应类型
*/
export interface BatchDownloadResponse {
success: DownloadResponse[]
failed: Array<{
url: string
error: string
}>
total: number
successCount: number
failedCount: number
}
/**
* 文件下载类
*/
export class FileDownloader {
private downloadQueue: Map<string, AbortController> = new Map()
/**
* 下载文件
*/
public async downloadFile(config: DownloadConfig): Promise<DownloadResponse> {
const {
url,
filename,
params,
onProgress
} = config
// 生成下载ID
const downloadId = generateId()
const controller = new AbortController()
this.downloadQueue.set(downloadId, controller)
try {
const response = await httpClient.request<Blob>({
url,
method: 'GET',
params,
responseType: 'blob',
timeout: 0, // 下载不设置超时
})
if (!response.success) {
throw new Error(response.message)
}
const blob = response.data
const contentType = blob.type || 'application/octet-stream'
// 从响应头或URL中获取文件名
const finalFilename = filename || this.extractFilenameFromUrl(url) || 'download'
return {
blob,
filename: finalFilename,
size: blob.size,
type: contentType
}
} finally {
this.downloadQueue.delete(downloadId)
}
}
/**
* 下载并保存文件
*/
public async downloadAndSave(config: DownloadConfig): Promise<void> {
const result = await this.downloadFile(config)
downloadFile(result.blob, result.filename)
}
/**
* 下载文件为Base64
*/
public async downloadAsBase64(config: DownloadConfig): Promise<string> {
const result = await this.downloadFile(config)
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const base64 = reader.result as string
resolve(base64.split(',')[1]) // 移除data:xxx;base64,前缀
}
reader.onerror = reject
reader.readAsDataURL(result.blob)
})
}
/**
* 下载文件为ArrayBuffer
*/
public async downloadAsArrayBuffer(config: DownloadConfig): Promise<ArrayBuffer> {
const result = await this.downloadFile(config)
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = reject
reader.readAsArrayBuffer(result.blob)
})
}
/**
* 下载文件为文本
*/
public async downloadAsText(config: DownloadConfig): Promise<string> {
const result = await this.downloadFile(config)
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsText(result.blob)
})
}
/**
* 分片下载(大文件)
*/
public async downloadLargeFile(
config: DownloadConfig & {
chunkSize?: number
onChunkProgress?: (chunkIndex: number, chunkProgress: number) => void
}
): Promise<DownloadResponse> {
const {
url,
filename,
params,
chunkSize = 2 * 1024 * 1024, // 2MB per chunk
onProgress,
onChunkProgress
} = config
try {
// 获取文件信息
const headResponse = await httpClient.request({
url,
method: 'HEAD',
params
})
if (!headResponse.success) {
throw new Error('无法获取文件信息')
}
// 这里需要根据实际的响应头来获取文件大小
// const fileSize = parseInt(headResponse.headers['content-length'] || '0')
const fileSize = 0 // 临时设置,实际应该从响应头获取
if (fileSize === 0) {
// 如果无法获取文件大小,回退到普通下载
return this.downloadFile(config)
}
const totalChunks = Math.ceil(fileSize / chunkSize)
const chunks: Blob[] = []
// 下载分片
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize - 1, fileSize - 1)
const chunkResponse = await httpClient.request<Blob>({
url,
method: 'GET',
params,
headers: {
'Range': `bytes=${start}-${end}`
},
responseType: 'blob'
})
if (chunkResponse.success) {
chunks.push(chunkResponse.data)
// 分片进度回调
onChunkProgress?.(i, 100)
// 总进度回调
const progress = ((i + 1) / totalChunks) * 100
onProgress?.(progress)
} else {
throw new Error(`分片 ${i} 下载失败: ${chunkResponse.message}`)
}
}
// 合并分片
const blob = new Blob(chunks)
const finalFilename = filename || this.extractFilenameFromUrl(url) || 'download'
return {
blob,
filename: finalFilename,
size: blob.size,
type: blob.type || 'application/octet-stream'
}
} catch (error) {
throw new Error(`分片下载失败: ${error}`)
}
}
/**
* 批量下载
*/
public async downloadBatch(
urls: string[],
options: {
concurrent?: number
onProgress?: (progress: number) => void
onFileProgress?: (url: string, progress: number) => void
onFileComplete?: (url: string, result: DownloadResponse | Error) => void
} = {}
): Promise<BatchDownloadResponse> {
const {
concurrent = 3,
onProgress,
onFileProgress,
onFileComplete
} = options
const results: BatchDownloadResponse = {
success: [],
failed: [],
total: urls.length,
successCount: 0,
failedCount: 0
}
const downloadUrl = async (url: string): Promise<void> => {
try {
const response = await this.downloadFile({
url,
onProgress: (progress) => onFileProgress?.(url, progress)
})
results.success.push(response)
results.successCount++
onFileComplete?.(url, response)
} catch (error) {
results.failed.push({
url,
error: error instanceof Error ? error.message : '下载失败'
})
results.failedCount++
onFileComplete?.(url, error as Error)
}
// 更新总进度
const completed = results.successCount + results.failedCount
const progress = (completed / results.total) * 100
onProgress?.(progress)
}
// 并发下载
const chunks = []
for (let i = 0; i < urls.length; i += concurrent) {
chunks.push(urls.slice(i, i + concurrent))
}
for (const chunk of chunks) {
await Promise.all(chunk.map(downloadUrl))
}
return results
}
/**
* 批量下载并打包为ZIP
*/
public async downloadAsZip(
files: Array<{
url: string
filename?: string
}>,
zipFilename: string = 'download.zip'
): Promise<void> {
try {
// 下载所有文件
const downloadPromises = files.map(async (file) => {
const result = await this.downloadFile({
url: file.url,
filename: file.filename
})
return {
filename: result.filename,
blob: result.blob
}
})
const downloadedFiles = await Promise.all(downloadPromises)
// 这里需要使用JSZip库来创建ZIP文件
// 由于没有导入JSZip这里只是示例代码
console.log('需要集成JSZip库来实现ZIP打包功能')
console.log('下载的文件:', downloadedFiles)
// 示例如果有JSZip
// const JSZip = (await import('jszip')).default
// const zip = new JSZip()
//
// downloadedFiles.forEach(file => {
// zip.file(file.filename, file.blob)
// })
//
// const zipBlob = await zip.generateAsync({ type: 'blob' })
// downloadFile(zipBlob, zipFilename)
} catch (error) {
throw new Error(`ZIP下载失败: ${error}`)
}
}
/**
* 从URL中提取文件名
*/
private extractFilenameFromUrl(url: string): string {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const filename = pathname.split('/').pop()
return filename || 'download'
} catch {
return 'download'
}
}
/**
* 取消下载
*/
public cancelDownload(downloadId: string): void {
const controller = this.downloadQueue.get(downloadId)
if (controller) {
controller.abort()
this.downloadQueue.delete(downloadId)
}
}
/**
* 取消所有下载
*/
public cancelAllDownloads(): void {
this.downloadQueue.forEach((controller) => {
controller.abort()
})
this.downloadQueue.clear()
}
/**
* 获取下载进度
*/
public getDownloadProgress(downloadId: string): number {
// 这里可以实现获取具体下载进度的逻辑
return 0
}
}
// 创建默认实例
export const fileDownloader = new FileDownloader()

344
src/api/core/request.ts Normal file
View File

@ -0,0 +1,344 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { ApiResponse, RequestConfig, ApiError } from '../types'
import { API_CONFIG, ENV_CONFIG } from '../config'
import { createApiError, retry } from '../utils'
/**
* HTTP请求客户端类
*/
export class HttpClient {
private instance: AxiosInstance
private requestQueue: Map<string, AbortController> = new Map()
constructor() {
this.instance = this.createInstance()
this.setupInterceptors()
}
/**
* 创建axios实例
*/
private createInstance(): AxiosInstance {
return axios.create({
baseURL: API_CONFIG.BASE_URL,
timeout: API_CONFIG.TIMEOUT,
headers: API_CONFIG.DEFAULT_HEADERS,
})
}
/**
* 设置拦截器
*/
private setupInterceptors(): void {
// 请求拦截器
this.instance.interceptors.request.use(
(config) => {
// 添加请求ID用于取消请求
const requestId = this.generateRequestId(config)
config.metadata = { requestId }
// 添加认证token
const token = this.getAuthToken()
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
// 开发环境下打印请求信息
if (ENV_CONFIG.isDev) {
console.log('🚀 Request:', {
url: config.url,
method: config.method,
params: config.params,
data: config.data,
})
}
return config
},
(error) => {
return Promise.reject(this.handleError(error))
}
)
// 响应拦截器
this.instance.interceptors.response.use(
(response) => {
// 清理请求队列
const requestId = response.config.metadata?.requestId
if (requestId) {
this.requestQueue.delete(requestId)
}
// 开发环境下打印响应信息
if (ENV_CONFIG.isDev) {
console.log('✅ Response:', {
url: response.config.url,
status: response.status,
data: response.data,
})
}
// 直接返回response在具体的请求方法中处理数据格式
return response
},
(error) => {
// 清理请求队列
const requestId = error.config?.metadata?.requestId
if (requestId) {
this.requestQueue.delete(requestId)
}
return Promise.reject(this.handleError(error))
}
)
}
/**
* 处理响应数据
*/
private handleResponse<T>(response: AxiosResponse): ApiResponse<T> {
const { data } = response
// 如果后端返回的数据已经是标准格式
if (data && typeof data.code !== 'undefined') {
return {
code: data.code,
message: data.message || 'Success',
data: data.data,
success: data.code === API_CONFIG.BUSINESS_CODES.SUCCESS,
}
}
// 否则包装成标准格式
return {
code: API_CONFIG.BUSINESS_CODES.SUCCESS,
message: 'Success',
data: data,
success: true,
}
}
/**
* 处理错误
*/
private handleError(error: any): ApiError {
if (ENV_CONFIG.isDev) {
console.error('❌ Request Error:', error)
}
// 请求被取消
if (axios.isCancel(error)) {
return createApiError(-1, '请求已取消')
}
// 网络错误
if (!error.response) {
return createApiError(-1, '网络连接失败,请检查网络设置')
}
const { status, data } = error.response as AxiosResponse
// 根据HTTP状态码处理
switch (status) {
case API_CONFIG.STATUS_CODES.UNAUTHORIZED:
this.handleUnauthorized()
return createApiError(status, '登录已过期,请重新登录')
case API_CONFIG.STATUS_CODES.FORBIDDEN:
return createApiError(status, '没有权限访问该资源')
case API_CONFIG.STATUS_CODES.NOT_FOUND:
return createApiError(status, '请求的资源不存在')
case API_CONFIG.STATUS_CODES.SERVER_ERROR:
return createApiError(status, '服务器内部错误')
default:
return createApiError(
status,
data?.message || `请求失败 (${status})`,
data
)
}
}
/**
* 处理未授权
*/
private handleUnauthorized(): void {
// 清除token
this.clearAuthToken()
// 可以在这里添加跳转到登录页的逻辑
// router.push('/login')
}
/**
* 生成请求ID
*/
private generateRequestId(config: AxiosRequestConfig): string {
return `${config.method}_${config.url}_${Date.now()}`
}
/**
* 获取认证token
*/
private getAuthToken(): string | null {
return localStorage.getItem('auth_token')
}
/**
* 清除认证token
*/
private clearAuthToken(): void {
localStorage.removeItem('auth_token')
}
/**
* 设置认证token
*/
public setAuthToken(token: string): void {
localStorage.setItem('auth_token', token)
}
/**
* 通用请求方法
*/
public async request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
const axiosConfig: AxiosRequestConfig = {
url: config.url,
method: config.method || 'GET',
params: config.params,
data: config.data,
headers: config.headers,
timeout: config.timeout,
responseType: config.responseType || 'json',
}
const response = await this.instance.request(axiosConfig)
return this.handleResponse<T>(response)
}
/**
* 带重试的请求方法
*/
public async requestWithRetry<T = any>(
config: RequestConfig,
retries?: number
): Promise<ApiResponse<T>> {
return retry(() => this.request<T>(config), retries)
}
/**
* GET请求
*/
public get<T = any>(
url: string,
params?: Record<string, any>,
config?: Partial<RequestConfig>
): Promise<ApiResponse<T>> {
return this.request<T>({
url,
method: 'GET',
params,
...config,
})
}
/**
* POST请求
*/
public post<T = any>(
url: string,
data?: any,
config?: Partial<RequestConfig>
): Promise<ApiResponse<T>> {
return this.request<T>({
url,
method: 'POST',
data,
...config,
})
}
/**
* PUT请求
*/
public put<T = any>(
url: string,
data?: any,
config?: Partial<RequestConfig>
): Promise<ApiResponse<T>> {
return this.request<T>({
url,
method: 'PUT',
data,
...config,
})
}
/**
* DELETE请求
*/
public delete<T = any>(
url: string,
params?: Record<string, any>,
config?: Partial<RequestConfig>
): Promise<ApiResponse<T>> {
return this.request<T>({
url,
method: 'DELETE',
params,
...config,
})
}
/**
* PATCH请求
*/
public patch<T = any>(
url: string,
data?: any,
config?: Partial<RequestConfig>
): Promise<ApiResponse<T>> {
return this.request<T>({
url,
method: 'PATCH',
data,
...config,
})
}
/**
* 取消请求
*/
public cancelRequest(requestId: string): void {
const controller = this.requestQueue.get(requestId)
if (controller) {
controller.abort()
this.requestQueue.delete(requestId)
}
}
/**
* 取消所有请求
*/
public cancelAllRequests(): void {
this.requestQueue.forEach((controller) => {
controller.abort()
})
this.requestQueue.clear()
}
}
// 创建默认实例
export const httpClient = new HttpClient()
// 扩展axios配置类型
declare module 'axios' {
interface AxiosRequestConfig {
metadata?: {
requestId: string
}
}
}

362
src/api/core/upload.ts Normal file
View File

@ -0,0 +1,362 @@
import { AxiosRequestConfig } from 'axios'
import { httpClient } from './request'
import { UploadConfig, ApiResponse } from '../types'
import { API_CONFIG, API_ENDPOINTS } from '../config'
import {
validateFileType,
formatFileSize,
compressImage,
readFileAsBase64,
generateId
} from '../utils'
/**
* 文件上传响应类型
*/
export interface UploadResponse {
fileId: string
filename: string
url: string
size: number
type: string
uploadTime: string
}
/**
* 批量上传响应类型
*/
export interface BatchUploadResponse {
success: UploadResponse[]
failed: Array<{
file: File
error: string
}>
total: number
successCount: number
failedCount: number
}
/**
* 文件上传类
*/
export class FileUploader {
private uploadQueue: Map<string, AbortController> = new Map()
/**
* 单文件上传
*/
public async uploadFile(config: UploadConfig): Promise<ApiResponse<UploadResponse>> {
const {
file,
filename = file instanceof File ? file.name : 'blob',
fieldName = 'file',
data = {},
onProgress
} = config
// 创建FormData
const formData = new FormData()
formData.append(fieldName, file, filename)
// 添加额外数据
Object.keys(data).forEach(key => {
formData.append(key, data[key])
})
// 生成上传ID
const uploadId = generateId()
const controller = new AbortController()
this.uploadQueue.set(uploadId, controller)
try {
const response = await httpClient.request<UploadResponse>({
url: API_ENDPOINTS.FILE.UPLOAD,
method: 'POST',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: 0, // 上传不设置超时
responseType: 'json',
})
return response
} finally {
this.uploadQueue.delete(uploadId)
}
}
/**
* 图片上传(带压缩)
*/
public async uploadImage(
file: File,
options: {
compress?: boolean
quality?: number
maxWidth?: number
maxHeight?: number
allowedTypes?: string[]
maxSize?: number
onProgress?: (progress: number) => void
} = {}
): Promise<ApiResponse<UploadResponse>> {
const {
compress = true,
quality = 0.8,
maxWidth = 1920,
maxHeight = 1080,
allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'],
maxSize = 10 * 1024 * 1024, // 10MB
onProgress
} = options
// 验证文件类型
if (!validateFileType(file, allowedTypes)) {
throw new Error(`不支持的文件类型,仅支持: ${allowedTypes.join(', ')}`)
}
// 验证文件大小
if (file.size > maxSize) {
throw new Error(`文件大小超出限制,最大支持: ${formatFileSize(maxSize)}`)
}
let uploadFile = file
// 压缩图片
if (compress && file.type.startsWith('image/')) {
try {
const compressedBlob = await compressImage(file, quality, maxWidth, maxHeight)
uploadFile = new File([compressedBlob], file.name, { type: file.type })
} catch (error) {
console.warn('图片压缩失败,使用原文件上传:', error)
}
}
return this.uploadFile({
file: uploadFile,
onProgress
})
}
/**
* Base64上传
*/
public async uploadBase64(
file: File,
options: {
onProgress?: (progress: number) => void
} = {}
): Promise<ApiResponse<UploadResponse>> {
const { onProgress } = options
try {
const base64 = await readFileAsBase64(file)
const response = await httpClient.post<UploadResponse>(
API_ENDPOINTS.FILE.UPLOAD,
{
filename: file.name,
content: base64,
type: file.type,
size: file.size,
encoding: 'base64'
}
)
return response
} catch (error) {
throw new Error(`Base64上传失败: ${error}`)
}
}
/**
* 分片上传(大文件)
*/
public async uploadLargeFile(
file: File,
options: {
chunkSize?: number
onProgress?: (progress: number) => void
onChunkProgress?: (chunkIndex: number, chunkProgress: number) => void
} = {}
): Promise<ApiResponse<UploadResponse>> {
const {
chunkSize = 2 * 1024 * 1024, // 2MB per chunk
onProgress,
onChunkProgress
} = options
const totalChunks = Math.ceil(file.size / chunkSize)
const uploadId = generateId()
const uploadedChunks: string[] = []
try {
// 初始化分片上传
const initResponse = await httpClient.post('/file/upload/init', {
filename: file.name,
fileSize: file.size,
totalChunks,
uploadId
})
if (!initResponse.success) {
throw new Error(initResponse.message)
}
// 上传分片
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('chunkIndex', i.toString())
formData.append('uploadId', uploadId)
const chunkResponse = await httpClient.post('/file/upload/chunk', formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
})
if (chunkResponse.success) {
uploadedChunks.push(chunkResponse.data.chunkId)
// 分片进度回调
onChunkProgress?.(i, 100)
// 总进度回调
const progress = ((i + 1) / totalChunks) * 100
onProgress?.(progress)
} else {
throw new Error(`分片 ${i} 上传失败: ${chunkResponse.message}`)
}
}
// 合并分片
const mergeResponse = await httpClient.post<UploadResponse>('/file/upload/merge', {
uploadId,
filename: file.name,
chunks: uploadedChunks
})
return mergeResponse
} catch (error) {
// 清理失败的上传
try {
await httpClient.post('/file/upload/cleanup', { uploadId })
} catch (cleanupError) {
console.warn('清理上传失败:', cleanupError)
}
throw error
}
}
/**
* 批量上传
*/
public async uploadBatch(
files: File[],
options: {
concurrent?: number
onProgress?: (progress: number) => void
onFileProgress?: (file: File, progress: number) => void
onFileComplete?: (file: File, result: UploadResponse | Error) => void
} = {}
): Promise<BatchUploadResponse> {
const {
concurrent = 3,
onProgress,
onFileProgress,
onFileComplete
} = options
const results: BatchUploadResponse = {
success: [],
failed: [],
total: files.length,
successCount: 0,
failedCount: 0
}
const uploadFile = async (file: File): Promise<void> => {
try {
const response = await this.uploadFile({
file,
onProgress: (progress) => onFileProgress?.(file, progress)
})
if (response.success) {
results.success.push(response.data)
results.successCount++
} else {
results.failed.push({
file,
error: response.message
})
results.failedCount++
}
onFileComplete?.(file, response.data)
} catch (error) {
results.failed.push({
file,
error: error instanceof Error ? error.message : '上传失败'
})
results.failedCount++
onFileComplete?.(file, error as Error)
}
// 更新总进度
const completed = results.successCount + results.failedCount
const progress = (completed / results.total) * 100
onProgress?.(progress)
}
// 并发上传
const chunks = []
for (let i = 0; i < files.length; i += concurrent) {
chunks.push(files.slice(i, i + concurrent))
}
for (const chunk of chunks) {
await Promise.all(chunk.map(uploadFile))
}
return results
}
/**
* 取消上传
*/
public cancelUpload(uploadId: string): void {
const controller = this.uploadQueue.get(uploadId)
if (controller) {
controller.abort()
this.uploadQueue.delete(uploadId)
}
}
/**
* 取消所有上传
*/
public cancelAllUploads(): void {
this.uploadQueue.forEach((controller) => {
controller.abort()
})
this.uploadQueue.clear()
}
/**
* 获取上传进度
*/
public getUploadProgress(uploadId: string): number {
// 这里可以实现获取具体上传进度的逻辑
return 0
}
}
// 创建默认实例
export const fileUploader = new FileUploader()