eagleye

Quasar+DRF企业级通用文件上传组件

# 企业级通用文件上传组件

下面是一个优化合并后的企业级通用文件上传组件,支持图片文件、PDF文件以及Office文档(.docx, .pptx, .xlsx)的上传功能。

```vue
<!-- UniversalFileUploader.vue -->
<template>
<div class="universal-file-uploader">
<!-- 文件选择区域 -->
<q-card class="upload-card q-mb-md">
<q-card-section>
<div class="text-h6">文件上传</div>

<!-- 分类选择 -->
<q-select
v-model="currentCategory"
:options="categories"
label="选择分类"
class="q-mb-md"
emit-value
map-options
/>

<!-- 文件类型筛选 -->
<div class="file-type-filter q-mb-md">
<q-btn-toggle
v-model="currentFileType"
spread
no-caps
:options="fileTypeOptions"
class="q-mb-sm"
/>
<q-checkbox
v-model="allowMultiple"
label="允许多选"
class="q-mt-sm"
/>
</div>

<!-- 文件选择 -->
<q-file
v-model="localFiles"
:multiple="allowMultiple"
:accept="currentAcceptTypes"
:label="fileInputLabel"
counter
@update:model-value="handleFilesSelected"
>
<template v-slot:prepend>
<q-icon name="attach_file" />
</template>
</q-file>

<!-- 上传按钮 -->
<q-btn
color="primary"
icon="cloud_upload"
:label="uploadButtonLabel"
class="q-mt-md full-width"
:loading="uploading"
:disable="!canUpload"
@click="uploadFiles"
/>
</q-card-section>
</q-card>

<!-- 文件列表 -->
<q-card v-if="fileList.length > 0" class="files-card">
<q-card-section>
<div class="text-h6">已选择文件 ({{ fileList.length }})</div>
<q-btn
v-if="fileList.length > 1"
flat
dense
icon="delete_sweep"
label="清空列表"
class="q-mt-sm"
@click="clearAllFiles"
/>
</q-card-section>
<q-separator />
<q-list separator>
<q-item
v-for="(fileItem, index) in fileList"
:key="fileItem.id"
class="file-item"
>
<q-item-section avatar>
<q-avatar rounded :color="getFileIconColor(fileItem.type)" text-color="white">
<q-icon :name="getFileIcon(fileItem.type)" />
</q-avatar>
</q-item-section>

<q-item-section>
<q-item-label>{{ fileItem.name }}</q-item-label>
<q-item-label caption>
{{ formatFileSize(fileItem.size) }} · {{ getFileTypeName(fileItem.type) }}
</q-item-label>
</q-item-section>

<q-item-section side>
<q-btn
v-if="fileItem.previewUrl"
flat
round
color="info"
icon="visibility"
@click="showPreview(fileItem)"
class="q-mr-xs"
/>
<q-btn
flat
round
color="negative"
icon="delete"
@click="removeFile(index)"
/>
</q-item-section>
</q-item>
</q-list>
</q-card>

<!-- 上传进度 -->
<q-card v-if="uploading" class="progress-card q-mt-md">
<q-card-section>
<div class="text-h6">上传进度</div>
<q-btn
v-if="Object.keys(cancelTokens).length > 0"
flat
dense
icon="cancel"
label="取消所有上传"
class="q-mt-sm"
@click="cancelAllUploads"
/>
</q-card-section>
<q-separator />
<q-card-section>
<div v-for="item in Object.values(uploadProgress)" :key="item.id" class="q-mb-sm">
<div class="row items-center">
<div class="col-8">
<div class="text-caption">{{ item.name }}</div>
<div v-if="item.error" class="text-caption text-negative">{{ item.error }}</div>
</div>
<div class="col-4 text-right">
<div class="text-caption">{{ item.progress }}%</div>
</div>
</div>
<q-linear-progress
:value="item.progress / 100"
:color="getProgressColor(item.progress)"
class="q-mt-xs"
/>
</div>
</q-card-section>
</q-card>

<!-- 上传结果通知 -->
<q-dialog v-model="showUploadResult" persistent>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">上传结果</div>
</q-card-section>

<q-card-section class="q-pt-none">
<p>成功: {{ uploadResult.success }} 个文件</p>
<p v-if="uploadResult.failed > 0">失败: {{ uploadResult.failed }} 个文件</p>
<div v-if="failedFiles.length > 0" class="q-mt-md">
<p class="text-weight-bold">失败文件:</p>
<ul>
<li v-for="file in failedFiles" :key="file.id">{{ file.name }}</li>
</ul>
</div>
</q-card-section>

<q-card-actions align="right" class="text-primary">
<q-btn flat label="确定" v-close-popup />
<q-btn v-if="uploadResult.failed > 0" flat label="重试失败文件" @click="retryFailed" />
</q-card-actions>
</q-card>
</q-dialog>

<!-- 图片预览对话框 -->
<q-dialog v-model="showImagePreview" maximized>
<q-card>
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">{{ previewImage.name }}</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section class="flex flex-center">
<q-img
:src="previewImage.url"
style="max-height: 80vh; max-width: 80vw"
contain
/>
</q-card-section>
</q-card>
</q-dialog>
</div>
</template>

<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue';
import { useQuasar } from 'quasar';
import axios, { AxiosProgressEvent, CancelTokenSource } from 'axios';

// 类型定义
interface FileUploadItem {
id: string;
nativeFile: File;
name: string;
size: number;
type: string;
previewUrl?: string;
category: string;
}

interface UploadProgress {
id: string;
name: string;
progress: number;
completed: boolean;
error?: string;
}

interface UploadResult {
success: number;
failed: number;
}

interface FileTypeOption {
label: string;
value: string;
icon: string;
accept: string;
}

// Quasar插件
const $q = useQuasar();

// 响应式状态
const localFiles = ref<File[] | null>(null);
const fileList = ref<FileUploadItem[]>([]);
const currentCategory = ref<string>('general');
const currentFileType = ref<string>('all');
const allowMultiple = ref<boolean>(true);
const uploading = ref<boolean>(false);
const uploadProgress = ref<Record<string, UploadProgress>>({});
const showUploadResult = ref<boolean>(false);
const uploadResult = ref<UploadResult>({ success: 0, failed: 0 });
const cancelTokens = ref<Record<string, CancelTokenSource>>({});
const showImagePreview = ref<boolean>(false);
const previewImage = ref<{name: string; url: string}>({name: '', url: ''});

// 文件类型选项
const fileTypeOptions = ref<FileTypeOption[]>([
{ label: '所有文件', value: 'all', icon: 'folder', accept: '' },
{ label: '图片', value: 'images', icon: 'image', accept: 'image/*' },
{ label: 'PDF', value: 'pdf', icon: 'picture_as_pdf', accept: '.pdf' },
{ label: 'Office', value: 'office', icon: 'description', accept: '.docx, .pptx, .xlsx' },
]);

// 分类选项
const categories = ref<Array<{ label: string; value: string }>>([
{ label: '通用文件', value: 'general' },
{ label: '文档资料', value: 'documents' },
{ label: '演示文稿', value: 'presentations' },
{ label: '数据表格', value: 'spreadsheets' },
{ label: '报告材料', value: 'reports' },
{ label: '合同协议', value: 'contracts' },
]);

// 计算属性
const currentAcceptTypes = computed<string>(() => {
if (currentFileType.value === 'all') {
return 'image/*, .pdf, .docx, .pptx, .xlsx';
}

const option = fileTypeOptions.value.find(opt => opt.value === currentFileType.value);
return option ? option.accept : '';
});

const fileInputLabel = computed<string>(() => {
const option = fileTypeOptions.value.find(opt => opt.value === currentFileType.value);
const typeLabel = option ? option.label : '文件';
return `选择${typeLabel}${allowMultiple.value ? '(可多选)' : ''}`;
});

const uploadButtonLabel = computed<string>(() => {
return uploading.value ? '上传中...' : `开始上传 (${fileList.length})`;
});

const canUpload = computed<boolean>(() => {
return fileList.value.length > 0 && !uploading.value;
});

const failedFiles = computed(() => {
return fileList.value.filter(
fileItem => uploadProgress.value[fileItem.id]?.error && !uploadProgress.value[fileItem.id]?.completed
);
});

// 处理文件选择
const handleFilesSelected = (newFiles: File[] | null): void => {
if (!newFiles) return;

Array.from(newFiles).forEach((file: File) => {
// 检查文件类型
if (!isValidFileType(file)) {
$q.notify({
type: 'negative',
message: `不支持的文件类型: ${file.name}。请选择图片、PDF或Office文档。`,
});
return;
}

// 检查文件大小 (限制为20MB)
if (file.size > 20 * 1024 * 1024) {
$q.notify({
type: 'negative',
message: `文件过大: ${file.name} (最大20MB)`,
});
return;
}

// 检查是否已存在同名文件
if (fileList.value.some(item => item.name === file.name)) {
$q.notify({
type: 'warning',
message: `文件已存在: ${file.name}`,
});
return;
}

// 生成唯一ID
const id = generateFileId(file);

// 创建预览URL (如果是图片)
let previewUrl: string | undefined;
if (isImage(file)) {
previewUrl = URL.createObjectURL(file);
}

// 添加到文件列表
fileList.value.push({
id,
nativeFile: file,
name: file.name,
size: file.size,
type: file.type || getFileTypeFromName(file.name),
previewUrl,
category: currentCategory.value,
});
});

// 重置文件输入
localFiles.value = null;
};

// 移除文件
const removeFile = (index: number): void => {
const fileItem = fileList.value[index];

// 释放预览URL
if (fileItem.previewUrl) {
URL.revokeObjectURL(fileItem.previewUrl);
}

// 取消上传 (如果正在进行)
if (uploading.value && cancelTokens.value[fileItem.id]) {
cancelTokens.value[fileItem.id].cancel('用户取消上传');
delete cancelTokens.value[fileItem.id];
}

// 从列表中移除
fileList.value.splice(index, 1);
};

// 清空所有文件
const clearAllFiles = (): void => {
// 释放所有预览URL
fileList.value.forEach((fileItem) => {
if (fileItem.previewUrl) {
URL.revokeObjectURL(fileItem.previewUrl);
}
});

// 取消所有进行中的上传
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('用户清空文件列表');
});
cancelTokens.value = {};

// 清空文件列表
fileList.value = [];
uploading.value = false;
};

// 取消所有上传
const cancelAllUploads = (): void => {
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('用户取消所有上传');
});
cancelTokens.value = {};
uploading.value = false;

$q.notify({
type: 'info',
message: '已取消所有上传任务',
});
};

// 上传文件
const uploadFiles = async (): Promise<void> => {
if (fileList.value.length === 0) return;

uploading.value = true;
uploadResult.value = { success: 0, failed: 0 };

// 初始化上传进度
uploadProgress.value = {};
fileList.value.forEach((fileItem) => {
uploadProgress.value[fileItem.id] = {
id: fileItem.id,
name: fileItem.name,
progress: 0,
completed: false,
};
});

// 创建上传任务
const uploadPromises = fileList.value.map((fileItem) =>
uploadSingleFile(fileItem)
);

// 执行所有上传
try {
const results = await Promise.allSettled(uploadPromises);

// 统计结果
results.forEach((result) => {
if (result.status === 'fulfilled') {
uploadResult.value.success++;
} else {
uploadResult.value.failed++;
}
});

// 显示结果
showUploadResult.value = true;

// 成功通知
if (uploadResult.value.success > 0) {
$q.notify({
type: 'positive',
message: `成功上传 ${uploadResult.value.success} 个文件`,
actions: [
{
label: '查看',
color: 'white',
handler: () => {
// 这里可以添加查看上传文件的逻辑
}
}
]
});

// 移除已成功上传的文件
fileList.value = fileList.value.filter(
fileItem => !uploadProgress.value[fileItem.id]?.completed
);
}

// 失败通知
if (uploadResult.value.failed > 0) {
$q.notify({
type: 'warning',
message: `${uploadResult.value.failed} 个文件上传失败`,
});
}
} catch (error) {
$q.notify({
type: 'negative',
message: '上传过程发生错误',
});
} finally {
uploading.value = false;
}
};

// 上传单个文件
const uploadSingleFile = async (fileItem: FileUploadItem): Promise<void> => {
const formData = new FormData();
formData.append('file', fileItem.nativeFile);
formData.append('category', fileItem.category);
formData.append('original_name', fileItem.name);

// 确定上传端点
const endpoint = getUploadEndpoint(fileItem.type);

// 创建取消令牌
const source = axios.CancelToken.source();
cancelTokens.value[fileItem.id] = source;

try {
await axios.post(endpoint, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
cancelToken: source.token,
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
if (progressEvent.total) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
updateProgress(fileItem.id, progress);
}
},
timeout: 300000, // 5分钟超时
});

// 标记为完成
updateProgress(fileItem.id, 100, true);

} catch (error: unknown) {
if (axios.isCancel(error)) {
// 上传被取消,不视为错误
updateProgress(fileItem.id, 0, false, '已取消');
} else {
// 处理其他错误
const message = axios.isAxiosError(error)
? error.response?.data?.message || '上传失败'
: '上传失败';

updateProgress(fileItem.id, 0, false, message);
throw new Error(`上传失败: ${fileItem.name}`);
}
} finally {
// 清理取消令牌
delete cancelTokens.value[fileItem.id];
}
};

// 重试失败的上传
const retryFailed = (): void => {
showUploadResult.value = false;

// 过滤出失败的文件
const failedFiles = fileList.value.filter(
fileItem => !uploadProgress.value[fileItem.id]?.completed
);

// 只保留失败的文件
fileList.value = failedFiles;

// 重新上传
uploadFiles();
};

// 显示图片预览
const showPreview = (fileItem: FileUploadItem): void => {
if (fileItem.previewUrl) {
previewImage.value = {
name: fileItem.name,
url: fileItem.previewUrl
};
showImagePreview.value = true;
}
};

// 工具函数
const isValidFileType = (file: File): boolean => {
const validImageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const validPdfTypes = ['application/pdf'];
const validOfficeTypes = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];

const validTypes = [...validImageTypes, ...validPdfTypes, ...validOfficeTypes];

const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.docx', '.pptx', '.xlsx'];
const fileName = file.name.toLowerCase();

// 检查MIME类型
if (validTypes.includes(file.type)) {
return true;
}

// 检查文件扩展名
return validExtensions.some(ext => fileName.endsWith(ext));
};

const isImage = (file: File): boolean => {
return file.type.startsWith('image/');
};

const getFileTypeFromName = (fileName: string): string => {
const ext = fileName.toLowerCase().split('.').pop();
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
case 'webp':
return 'image/webp';
case 'pdf':
return 'application/pdf';
case 'docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
case 'pptx':
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
case 'xlsx':
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
default:
return 'application/octet-stream';
}
};

const getUploadEndpoint = (fileType: string): string => {
if (fileType.startsWith('image/')) {
return '/api/image-uploads/';
} else if (fileType === 'application/pdf') {
return '/api/pdf-uploads/';
} else if (fileType.includes('officedocument')) {
return '/api/office-uploads/';
}
return '/api/uploads/';
};

const getFileIcon = (fileType: string): string => {
if (fileType.startsWith('image/')) return 'image';
if (fileType === 'application/pdf') return 'picture_as_pdf';
if (fileType.includes('wordprocessingml')) return 'description';
if (fileType.includes('presentationml')) return 'slideshow';
if (fileType.includes('spreadsheetml')) return 'table_chart';
return 'insert_drive_file';
};

const getFileIconColor = (fileType: string): string => {
if (fileType.startsWith('image/')) return 'blue';
if (fileType === 'application/pdf') return 'red';
if (fileType.includes('wordprocessingml')) return 'blue';
if (fileType.includes('presentationml')) return 'orange';
if (fileType.includes('spreadsheetml')) return 'green';
return 'grey';
};

const getFileTypeName = (fileType: string): string => {
if (fileType.startsWith('image/')) return '图片';
if (fileType === 'application/pdf') return 'PDF文档';
if (fileType.includes('wordprocessingml')) return 'Word文档';
if (fileType.includes('presentationml')) return 'PowerPoint演示文稿';
if (fileType.includes('spreadsheetml')) return 'Excel电子表格';
return '未知类型';
};

const formatFileSize = (bytes: number): string => {
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));

return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};

const generateFileId = (file: File): string => {
return `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};

const updateProgress = (id: string, progress: number, completed: boolean = false, error?: string): void => {
if (uploadProgress.value[id]) {
uploadProgress.value[id] = {
...uploadProgress.value[id],
progress,
completed,
error,
};
}
};

const getProgressColor = (progress: number): string => {
if (progress === 100) return 'positive';
if (progress > 0) return 'primary';
return 'grey';
};

// 组件卸载时清理
onUnmounted(() => {
// 释放所有预览URL
fileList.value.forEach((fileItem) => {
if (fileItem.previewUrl) {
URL.revokeObjectURL(fileItem.previewUrl);
}
});

// 取消所有进行中的上传
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('组件卸载');
});
});
</script>

<style scoped lang="scss">
.universal-file-uploader {
max-width: 700px;
margin: 0 auto;
}

.upload-card, .files-card, .progress-card {
border-radius: 8px;
}

.file-type-filter {
.q-btn-toggle {
border-radius: 4px;

.q-btn {
padding: 4px 8px;

.q-icon {
font-size: 1.2em;
margin-right: 4px;
}
}
}
}

.file-item {
transition: background-color 0.3s;

&:hover {
background-color: #f5f5f5;
}
}
</style>
```

## 后端实现 (Django REST Framework)

### 通用上传视图

```python
# views.py
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db import transaction
import magic
import os

class UniversalUploadViewSet(viewsets.ModelViewSet):
"""
通用文件上传视图集,支持多种文件类型
"""

def get_queryset(self):
# 根据文件类型返回不同的查询集
file_type = self.request.query_params.get('type', 'all')

if file_type == 'images':
return UploadedImage.objects.all()
elif file_type == 'pdfs':
return UploadedPDF.objects.all()
elif file_type == 'office':
return OfficeDocument.objects.all()
else:
# 返回所有类型的文件
# 注意:这里需要根据实际模型调整
return UploadedFile.objects.all()

def get_serializer_class(self):
# 根据文件类型返回不同的序列化器
file_type = self.request.data.get('file_type', '')

if file_type.startswith('image/'):
return UploadedImageSerializer
elif file_type == 'application/pdf':
return UploadedPDFSerializer
elif 'officedocument' in file_type:
return OfficeDocumentSerializer
else:
return UploadedFileSerializer

def create(self, request, *args, **kwargs):
# 确定文件类型并选择相应的序列化器
file_obj = request.FILES.get('file')
if not file_obj:
return Response(
{'error': '没有提供文件'},
status=status.HTTP_400_BAD_REQUEST
)

# 使用python-magic检测文件类型
try:
file_type = magic.from_buffer(file_obj.read(2048), mime=True)
file_obj.seek(0) # 重置文件指针
except:
# 如果检测失败,使用文件名判断
file_type = self._get_file_type_from_name(file_obj.name)

# 设置文件类型到请求数据中
request.data['file_type'] = file_type

# 选择相应的序列化器
serializer_class = self.get_serializer_class()
serializer = serializer_class(data=request.data)

try:
serializer.is_valid(raise_exception=True)

with transaction.atomic():
instance = serializer.save()

headers = self.get_success_headers(serializer.data)
return Response(
serializer.data,
status=status.HTTP_201_CREATED,
headers=headers
)

except serializers.ValidationError as e:
return Response(
{'error': '文件验证失败', 'details': e.detail},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
return Response(
{'error': '上传过程中发生错误', 'details': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

def _get_file_type_from_name(self, file_name):
"""根据文件名获取文件类型"""
ext = os.path.splitext(file_name)[1].lower()
mime_types = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.pdf': 'application/pdf',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}

return mime_types.get(ext, 'application/octet-stream')

@action(detail=False, methods=['get'])
def stats(self, request):
"""获取上传统计信息"""
image_count = UploadedImage.objects.count()
pdf_count = UploadedPDF.objects.count()
office_count = OfficeDocument.objects.count()

return Response({
'images': image_count,
'pdfs': pdf_count,
'office_documents': office_count,
'total': image_count + pdf_count + office_count
})
```

### URL配置

```python
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import UniversalUploadViewSet

router = DefaultRouter()
router.register(r'universal-uploads', UniversalUploadViewSet, basename='universal-upload')

urlpatterns = [
path('api/', include(router.urls)),
]
```

## 使用示例

```vue
<!-- 在父组件中使用 -->
<template>
<div>
<universal-file-uploader />
</div>
</template>

<script setup lang="ts">
import UniversalFileUploader from '@/components/UniversalFileUploader.vue';
</script>
```

## 功能特点

### 1. 多文件类型支持
- 图片文件 (JPEG, PNG, GIF, WebP)
- PDF文档
- Office文档 (DOCX, PPTX, XLSX)

### 2. 智能文件分类
- 文件类型筛选器
- 自动识别文件类型
- 根据类型显示不同图标和颜色

### 3. 高级上传管理
- 多文件选择与上传
- 实时进度显示
- 取消上传功能
- 失败重试机制

### 4. 文件预览
- 图片文件预览功能
- 文件信息显示(名称、大小、类型)

### 5. 企业级特性
- 完整的错误处理
- 文件大小限制 (20MB)
- 文件类型验证
- 重复文件检测
- 分类管理

### 6. 用户体验优化
- 直观的文件类型图标
- 清晰的上传状态反馈
- 批量操作支持(清空列表、取消所有上传)
- 响应式设计

这个通用文件上传组件提供了完整的企业级文件上传解决方案,支持多种文件类型,具有优秀的用户体验和强大的功能,适合在生产环境中使用。

posted on 2025-08-20 09:12  GoGrid  阅读(7)  评论(0)    收藏  举报

导航