eagleye

Quasar+DRF企业级 Office 文档上传解决方案

# 企业级 Office 文档上传解决方案

下面我将提供一个完整的企业级解决方案,支持 `.docx`、`.pptx`、`.xlsx` 格式文件上传,包含前后端完整实现。

## 前端组件实现 (TypeScript + Quasar)

```vue
<!-- OfficeFileUploader.vue -->
<template>
<div class="office-file-uploader">
<!-- 文件选择区域 -->
<q-card class="upload-card q-mb-md">
<q-card-section>
<div class="text-h6">Office 文档上传</div>
<q-select
v-model="currentCategory"
:options="categories"
label="选择分类"
class="q-mb-md"
emit-value
map-options
/>
<q-file
v-model="localFiles"
multiple
:accept="acceptTypes"
label="选择Office文档 (.docx, .pptx, .xlsx)"
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="开始上传"
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-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
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-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>
</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;
category: string;
}

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

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

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

// 响应式状态
const localFiles = ref<File[] | null>(null);
const fileList = ref<FileUploadItem[]>([]);
const currentCategory = ref<string>('documents');
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 acceptTypes = computed<string>(() => {
return '.docx, .pptx, .xlsx, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
});

// 分类选项
const categories = ref<Array<{ label: string; value: string }>>([
{ label: '文档', value: 'documents' },
{ label: '演示文稿', value: 'presentations' },
{ label: '电子表格', value: 'spreadsheets' },
{ label: '报告', value: 'reports' },
{ label: '合同', value: 'contracts' },
]);

// 失败的文件列表
const failedFiles = computed(() => {
return fileList.value.filter(
fileItem => uploadProgress.value[fileItem.id]?.error && !uploadProgress.value[fileItem.id]?.completed
);
});

// 是否可以上传
const canUpload = computed<boolean>(() => {
return fileList.value.length > 0 && !uploading.value;
});

// 处理文件选择
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}. 只支持 .docx, .pptx, .xlsx 格式`,
});
return;
}

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

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

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

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

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

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

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

// 上传文件
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} 个文件`,
});
}

// 失败通知
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 source = axios.CancelToken.source();
cancelTokens.value[fileItem.id] = source;

try {
await axios.post('/api/office-uploads/', 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 isValidFileType = (file: File): boolean => {
const validTypes = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];

const validExtensions = ['.docx', '.pptx', '.xlsx'];
const fileName = file.name.toLowerCase();

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

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

const getFileTypeFromName = (fileName: string): string => {
const ext = fileName.toLowerCase().split('.').pop();
switch (ext) {
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 getFileIcon = (fileType: string): string => {
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.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.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(() => {
// 取消所有进行中的上传
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('组件卸载');
});
});
</script>

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

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

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

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

## 后端实现 (Django REST Framework)

### 模型 (models.py)

```python
# models.py
from django.db import models
from django.utils import timezone
import os
from typing import Optional

class OfficeDocument(models.Model):
CATEGORY_CHOICES = [
('documents', '文档'),
('presentations', '演示文稿'),
('spreadsheets', '电子表格'),
('reports', '报告'),
('contracts', '合同'),
]

file = models.FileField(upload_to='office_documents/%Y/%m/%d/')
original_name = models.CharField(max_length=255)
size = models.BigIntegerField()
file_type = models.CharField(max_length=100)
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='documents')
uploaded_at = models.DateTimeField(default=timezone.now)
checksum = models.CharField(max_length=64, blank=True) # 用于文件去重

def save(self, *args, **kwargs):
# 自动填充文件信息
if self.file:
self.original_name = self.file.name
self.size = self.file.size
self.file_type = self._get_file_content_type()
self.checksum = self._calculate_checksum()

super().save(*args, **kwargs)

def _get_file_content_type(self) -> str:
"""获取文件的MIME类型"""
# 通过文件扩展名判断
ext = os.path.splitext(self.file.name)[1].lower()
mime_types = {
'.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')

def _calculate_checksum(self) -> str:
"""计算文件校验和用于去重"""
import hashlib

if not hasattr(self.file, 'file'):
return ''

try:
hash_sha256 = hashlib.sha256()
for chunk in self.file.chunks(chunk_size=4096):
hash_sha256.update(chunk)
return hash_sha256.hexdigest()
except (IOError, AttributeError):
return ''

def __str__(self):
return self.original_name

class Meta:
ordering = ['-uploaded_at']
verbose_name = 'Office文档'
verbose_name_plural = 'Office文档'
```

### 序列化器 (serializers.py)

```python
# serializers.py
from rest_framework import serializers
from .models import OfficeDocument
import magic

class OfficeDocumentSerializer(serializers.ModelSerializer):
class Meta:
model = OfficeDocument
fields = ['id', 'file', 'original_name', 'size', 'file_type', 'category', 'uploaded_at']
read_only_fields = ['id', 'original_name', 'size', 'file_type', 'uploaded_at']

def validate_file(self, value):
# 验证文件大小 (20MB限制)
max_size = 20 * 1024 * 1024 # 20MB
if value.size > max_size:
raise serializers.ValidationError(f"文件大小不能超过 {max_size//1024//1024}MB")

# 验证文件类型
valid_types = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
]

valid_extensions = ['.docx', '.pptx', '.xlsx']

# 使用python-magic库进行文件类型检测
try:
file_type = magic.from_buffer(value.read(2048), mime=True)
value.seek(0) # 重置文件指针

# 验证MIME类型
if file_type not in valid_types:
raise serializers.ValidationError("不支持的文件类型")

except ImportError:
# 如果没有安装python-magic,使用文件名扩展判断
name = value.name.lower()
if not any(name.endswith(ext) for ext in valid_extensions):
raise serializers.ValidationError("不支持的文件类型")

return value

def validate(self, data):
# 检查是否已存在相同文件 (基于校验和)
if 'file' in data and data['file']:
instance = OfficeDocument()
instance.file = data['file']
checksum = instance._calculate_checksum()

if OfficeDocument.objects.filter(checksum=checksum).exists():
raise serializers.ValidationError("该文件已存在")

return data
```

### 视图 (views.py)

```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
from .models import OfficeDocument
from .serializers import OfficeDocumentSerializer

class OfficeDocumentViewSet(viewsets.ModelViewSet):
queryset = OfficeDocument.objects.all()
serializer_class = OfficeDocumentSerializer

def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)

try:
serializer.is_valid(raise_exception=True)

with transaction.atomic():
# 保存原始文件名
original_name = request.data.get('original_name', '')
instance = serializer.save()

if original_name:
instance.original_name = original_name
instance.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
)

@action(detail=False, methods=['post'])
def bulk_upload(self, request):
"""批量上传文件接口"""
files = request.FILES.getlist('files')
category = request.data.get('category', 'documents')
results = []

for file in files:
file_data = {
'file': file,
'category': category,
'original_name': file.name
}

serializer = self.get_serializer(data=file_data)
try:
serializer.is_valid(raise_exception=True)
instance = serializer.save()
results.append({
'success': True,
'file': serializer.data,
'message': '上传成功'
})
except Exception as e:
results.append({
'success': False,
'name': file.name,
'message': str(e)
})

return Response({'results': results}, status=status.HTTP_207_MULTI_STATUS)
```

### URL配置 (urls.py)

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

router = DefaultRouter()
router.register(r'office-uploads', OfficeDocumentViewSet, basename='office-upload')

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

### 安装依赖

为了获得最佳的文件类型检测效果,需要安装 `python-magic`:

```bash
pip install python-magic
# Windows 用户还需要安装
pip install python-magic-bin
```

## 使用示例

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

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

## 功能特点

### 前端功能
1. **支持多种Office文档格式**:
- .docx (Word文档)
- .pptx (PowerPoint演示文稿)
- .xlsx (Excel电子表格)

2. **直观的文件类型显示**:
- 不同文件类型使用不同图标和颜色
- 显示文件类型名称和大小

3. **完整的上传管理**:
- 进度跟踪
- 错误处理
- 取消上传功能
- 失败文件重试

4. **文件验证**:
- 文件类型验证
- 文件大小限制 (20MB)

### 后端功能
1. **安全的文件处理**:
- 文件类型验证 (使用python-magic)
- 文件大小限制
- 文件去重 (基于SHA256校验和)

2. **元数据管理**:
- 保存原始文件名
- 记录文件大小和类型
- 分类管理

3. **批量上传支持**:
- 支持多文件同时上传
- 返回详细的上传结果

4. **错误处理**:
- 详细的错误信息返回
- 适当的HTTP状态码

这个解决方案提供了完整的企业级Office文档上传功能,具有类型安全的TypeScript实现和良好的用户体验,适合在生产环境中使用。

posted on 2025-08-20 08:40  GoGrid  阅读(14)  评论(0)    收藏  举报

导航