- Update food, outfits, props, home-decor pages and components - Add permissions page and sidebar updates - Update API client and all API modules (auth, food, dances, etc.) - Add card model migrations for optional fields - Update Django views, serializers, and authentication - Add affinity level migrations and user app updates - Add project documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
import { apiClient } from "./client";
|
||
import type { ApiResponse } from "./types";
|
||
|
||
// 上传文件响应类型
|
||
export interface UploadResponse {
|
||
url: string;
|
||
filename: string;
|
||
size: number;
|
||
mimeType: string;
|
||
}
|
||
|
||
// 上传进度回调类型
|
||
export type UploadProgressCallback = (progressEvent: {
|
||
loaded: number;
|
||
total: number;
|
||
percentage: number;
|
||
}) => void;
|
||
|
||
/**
|
||
* 上传单个文件
|
||
* @param file 要上传的文件
|
||
* @param onProgress 上传进度回调函数
|
||
* @returns 上传结果
|
||
*/
|
||
export const uploadFile = async (
|
||
file: File,
|
||
onProgress?: UploadProgressCallback
|
||
): Promise<ApiResponse<UploadResponse>> => {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
console.log('📤 开始上传文件:', file.name, `(${(file.size / 1024 / 1024).toFixed(2)}MB)`);
|
||
|
||
const response = await apiClient.post('/common/upload/', formData, {
|
||
headers: {
|
||
'Content-Type': undefined as any, // Let axios/browser set multipart boundary automatically
|
||
},
|
||
onUploadProgress: (progressEvent) => {
|
||
const { loaded, total } = progressEvent;
|
||
const percentage = total ? Math.round((loaded * 100) / total) : 0;
|
||
|
||
console.log(`📊 上传进度: ${percentage}% (${(loaded / 1024 / 1024).toFixed(2)}MB / ${((total || 0) / 1024 / 1024).toFixed(2)}MB)`);
|
||
|
||
if (onProgress) {
|
||
onProgress({
|
||
loaded,
|
||
total: total || 0,
|
||
percentage,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
|
||
if (!response.data.success) {
|
||
throw new Error(response.data.message || '文件上传失败');
|
||
}
|
||
|
||
console.log('✅ 文件上传成功:', response.data.data);
|
||
return response.data;
|
||
} catch (error) {
|
||
console.error("文件上传失败:", error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 上传多个文件
|
||
* @param files 要上传的文件数组
|
||
* @param onProgress 上传进度回调函数
|
||
* @returns 上传结果数组
|
||
*/
|
||
export const uploadFiles = async (
|
||
files: File[],
|
||
onProgress?: UploadProgressCallback
|
||
): Promise<ApiResponse<UploadResponse[]>> => {
|
||
try {
|
||
const formData = new FormData();
|
||
files.forEach((file, index) => {
|
||
formData.append(`files`, file);
|
||
});
|
||
|
||
console.log('📤 开始批量上传文件:', files.length, '个文件');
|
||
|
||
const response = await apiClient.post('/common/upload/', formData, {
|
||
headers: {
|
||
'Content-Type': undefined as any, // Let axios/browser set multipart boundary automatically
|
||
},
|
||
onUploadProgress: (progressEvent) => {
|
||
const { loaded, total } = progressEvent;
|
||
const percentage = total ? Math.round((loaded * 100) / total) : 0;
|
||
|
||
console.log(`📊 批量上传进度: ${percentage}%`);
|
||
|
||
if (onProgress) {
|
||
onProgress({
|
||
loaded,
|
||
total: total || 0,
|
||
percentage,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
|
||
if (!response.data.success) {
|
||
throw new Error(response.data.message || '文件批量上传失败');
|
||
}
|
||
|
||
console.log('✅ 文件批量上传成功:', response.data.data);
|
||
return response.data;
|
||
} catch (error) {
|
||
console.error("文件批量上传失败:", error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 上传图片文件(带图片格式验证)
|
||
* @param file 要上传的图片文件
|
||
* @param onProgress 上传进度回调函数
|
||
* @returns 上传结果
|
||
*/
|
||
export const uploadImage = async (
|
||
file: File,
|
||
onProgress?: UploadProgressCallback
|
||
): Promise<ApiResponse<UploadResponse>> => {
|
||
// 验证文件类型
|
||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||
if (!allowedTypes.includes(file.type)) {
|
||
throw new Error('只支持 JPEG、PNG、GIF、WebP 格式的图片');
|
||
}
|
||
|
||
// 验证文件大小(限制10MB)
|
||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||
if (file.size > maxSize) {
|
||
throw new Error('图片文件大小不能超过 10MB');
|
||
}
|
||
|
||
return uploadFile(file, onProgress);
|
||
};
|
||
|
||
/**
|
||
* 上传头像图片(带特殊限制)
|
||
* @param file 要上传的头像图片
|
||
* @param onProgress 上传进度回调函数
|
||
* @returns 上传结果
|
||
*/
|
||
export const uploadAvatar = async (
|
||
file: File,
|
||
onProgress?: UploadProgressCallback
|
||
): Promise<ApiResponse<UploadResponse>> => {
|
||
// 验证文件类型
|
||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
|
||
if (!allowedTypes.includes(file.type)) {
|
||
throw new Error('头像只支持 JPEG、PNG 格式');
|
||
}
|
||
|
||
// 验证文件大小(限制2MB)
|
||
const maxSize = 2 * 1024 * 1024; // 2MB
|
||
if (file.size > maxSize) {
|
||
throw new Error('头像文件大小不能超过 2MB');
|
||
}
|
||
|
||
return uploadFile(file, onProgress);
|
||
};
|
||
|
||
/**
|
||
* 上传动画文件
|
||
* @param file 要上传的动画文件
|
||
* @param onProgress 上传进度回调函数
|
||
* @returns 上传结果
|
||
*/
|
||
export const uploadAnimation = async (
|
||
file: File,
|
||
onProgress?: UploadProgressCallback
|
||
): Promise<ApiResponse<UploadResponse>> => {
|
||
// 验证文件类型
|
||
const allowedTypes = [
|
||
'video/mp4', 'video/avi', 'video/mov', 'video/wmv', 'video/flv',
|
||
'image/gif', // GIF动画
|
||
'application/x-lottie', // Lottie动画
|
||
'text/json', // Lottie JSON格式
|
||
];
|
||
|
||
const isValidType = allowedTypes.includes(file.type) ||
|
||
file.name.toLowerCase().endsWith('.json') ||
|
||
file.name.toLowerCase().endsWith('.lottie');
|
||
|
||
if (!isValidType) {
|
||
throw new Error('只支持 MP4、AVI、MOV、WMV、FLV、GIF、Lottie 格式的动画文件');
|
||
}
|
||
|
||
// 验证文件大小(限制50MB)
|
||
const maxSize = 50 * 1024 * 1024; // 50MB
|
||
if (file.size > maxSize) {
|
||
throw new Error('动画文件大小不能超过 50MB');
|
||
}
|
||
|
||
console.log('📹 开始上传动画文件:', file.name, `(${(file.size / 1024 / 1024).toFixed(2)}MB)`);
|
||
return uploadFile(file, onProgress);
|
||
};
|
||
|
||
/**
|
||
* 上传音频文件
|
||
* @param file 要上传的音频文件
|
||
* @param onProgress 上传进度回调函数
|
||
* @returns 上传结果
|
||
*/
|
||
export const uploadAudio = async (
|
||
file: File,
|
||
onProgress?: UploadProgressCallback
|
||
): Promise<ApiResponse<UploadResponse>> => {
|
||
// 验证文件类型
|
||
const allowedTypes = [
|
||
'audio/mp3', 'audio/mpeg', 'audio/wav', 'audio/ogg',
|
||
'audio/aac', 'audio/flac', 'audio/wma', 'audio/m4a'
|
||
];
|
||
|
||
const isValidType = allowedTypes.includes(file.type) ||
|
||
/\.(mp3|wav|ogg|aac|flac|wma|m4a)$/i.test(file.name);
|
||
|
||
if (!isValidType) {
|
||
throw new Error('只支持 MP3、WAV、OGG、AAC、FLAC、WMA、M4A 格式的音频文件');
|
||
}
|
||
|
||
// 验证文件大小(限制20MB)
|
||
const maxSize = 20 * 1024 * 1024; // 20MB
|
||
if (file.size > maxSize) {
|
||
throw new Error('音频文件大小不能超过 20MB');
|
||
}
|
||
|
||
console.log('🎵 开始上传音频文件:', file.name, `(${(file.size / 1024 / 1024).toFixed(2)}MB)`);
|
||
return uploadFile(file, onProgress);
|
||
};
|
||
|
||
/**
|
||
* 通过URL上传文件
|
||
* @param url 文件URL
|
||
* @param filename 文件名
|
||
* @returns 上传结果
|
||
*/
|
||
export const uploadFromUrl = async (
|
||
url: string,
|
||
filename?: string
|
||
): Promise<ApiResponse<UploadResponse>> => {
|
||
try {
|
||
console.log('📤 开始从URL上传文件:', url);
|
||
|
||
const response = await apiClient.post('/common/upload/', {
|
||
url,
|
||
filename,
|
||
});
|
||
|
||
if (!response.data.success) {
|
||
throw new Error(response.data.message || 'URL文件上传失败');
|
||
}
|
||
|
||
console.log('✅ URL文件上传成功:', response.data.data);
|
||
return response.data;
|
||
} catch (error) {
|
||
console.error("URL文件上传失败:", error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 删除上传的文件
|
||
* @param url 文件URL或文件ID
|
||
* @returns 删除结果
|
||
*/
|
||
export const deleteUploadedFile = async (url: string): Promise<ApiResponse<{ message: string }>> => {
|
||
try {
|
||
console.log('🗑️ 删除上传文件:', url);
|
||
|
||
const response = await apiClient.delete('/common/upload/', {
|
||
data: { url }
|
||
});
|
||
|
||
if (!response.data.success) {
|
||
throw new Error(response.data.message || '文件删除失败');
|
||
}
|
||
|
||
console.log('✅ 文件删除成功');
|
||
return response.data;
|
||
} catch (error) {
|
||
console.error("文件删除失败:", error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 获取文件信息
|
||
* @param url 文件URL
|
||
* @returns 文件信息
|
||
*/
|
||
export const getFileInfo = async (url: string): Promise<ApiResponse<UploadResponse>> => {
|
||
try {
|
||
const response = await apiClient.get(`/common/upload/info/?url=${encodeURIComponent(url)}`);
|
||
|
||
if (!response.data.success) {
|
||
throw new Error(response.data.message || '获取文件信息失败');
|
||
}
|
||
|
||
return response.data;
|
||
} catch (error) {
|
||
console.error("获取文件信息失败:", error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// 文件类型检查工具函数
|
||
export const isImageFile = (file: File): boolean => {
|
||
if (!file || !file.type) return false;
|
||
return file.type.startsWith('image/');
|
||
};
|
||
|
||
export const isVideoFile = (file: File): boolean => {
|
||
if (!file || !file.type) return false;
|
||
return file.type.startsWith('video/');
|
||
};
|
||
|
||
export const isAudioFile = (file: File): boolean => {
|
||
if (!file) return false;
|
||
const hasAudioType = file.type && file.type.startsWith('audio/');
|
||
const hasAudioExtension = file.name && /\.(mp3|wav|ogg|aac|flac|wma|m4a)$/i.test(file.name);
|
||
return Boolean(hasAudioType || hasAudioExtension);
|
||
};
|
||
|
||
export const isAnimationFile = (file: File): boolean => {
|
||
if (!file) return false;
|
||
const hasVideoType = file.type && file.type.startsWith('video/');
|
||
const hasGifType = file.type && file.type === 'image/gif';
|
||
const hasLottieType = file.type && file.type === 'application/x-lottie';
|
||
const hasJsonName = file.name && file.name.toLowerCase().endsWith('.json');
|
||
const hasLottieName = file.name && file.name.toLowerCase().endsWith('.lottie');
|
||
|
||
return Boolean(hasVideoType || hasGifType || hasLottieType || hasJsonName || hasLottieName);
|
||
};
|
||
|
||
export const isPdfFile = (file: File): boolean => {
|
||
if (!file || !file.type) return false;
|
||
return file.type === 'application/pdf';
|
||
};
|
||
|
||
// 文件大小格式化工具函数
|
||
export const formatFileSize = (bytes: number): string => {
|
||
// 处理无效值
|
||
if (typeof bytes !== 'number' || isNaN(bytes) || bytes < 0) {
|
||
return '未知大小';
|
||
}
|
||
|
||
if (bytes === 0) return '0 Bytes';
|
||
|
||
const k = 1024;
|
||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
|
||
// 确保索引在有效范围内
|
||
const sizeIndex = Math.min(i, sizes.length - 1);
|
||
|
||
return parseFloat((bytes / Math.pow(k, sizeIndex)).toFixed(2)) + ' ' + sizes[sizeIndex];
|
||
}; |