Quasar 文件上传方法企业级实现
Quasar 文件上传方法企业级实现与存档文档
一、核心上传方案解析
1.1QUploader组件(推荐企业级方案)
Quasar 提供的开箱即用上传组件,集成完整 UI 和功能:
<template>
<q-uploader
url="/api/upload"
label="上传文件"
multiple
accept=".xlsx,.xls,.csv"
:headers="[{ name: 'Authorization', value: `Bearer ${token}` }]"
:form-fields="[{ name: 'category', value: 'user-data' }]"
@added="onFilesAdded"
@removed="onFileRemoved"
@uploaded="onUploaded"
@failed="onFailed"
style="max-width: 500px"
>
<!-- 自定义头部插槽 -->
<template v-slot:header="scope">
<div class="row no-wrap items-center q-pa-sm q-gutter-xs">
<q-btn
v-if="scope.canAddFiles"
icon="add"
round
dense
flat
>
<q-tooltip>添加文件</q-tooltip>
</q-btn>
<q-spinner v-if="scope.isUploading" class="q-uploader__spinner" />
<div class="col">
<div class="q-uploader__title">用户数据导入</div>
<div class="q-uploader__subtitle">
{{ scope.uploadSizeLabel }} / {{ scope.uploadProgressLabel }}
</div>
</div>
<q-btn
v-if="scope.canUpload"
icon="cloud_upload"
@click="scope.upload"
round
dense
flat
>
<q-tooltip>开始上传</q-tooltip>
</q-btn>
</div>
</template>
</q-uploader>
</template>
1.2 手动上传方法(精细化控制场景)
使用 Axios + FormData 实现完全自定义的上传逻辑:
const uploadFile = async (file) => {
try {
const formData = new FormData()
formData.append('file', file)
formData.append('category', 'user-data')
const response = await apiClient.post('/api/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': `Bearer ${authStore.token}`
},
onUploadProgress: (progressEvent) => {
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
console.log(`上传进度: ${percent}%`)
}
})
return response.data
} catch (error) {
console.error('文件上传失败:', error)
throw error
}
}
二、企业级全栈实现
2.1 后端实现(Django REST Framework)
# views.py
import os
import pandas as pd
from rest_framework.views import APIView
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response
from rest_framework import status
from .models import DataImportLog, User
class UserDataImportView(APIView):
parser_classes = [MultiPartParser]
def post(self, request):
"""企业级用户数据导入(含数据验证、错误处理、审计日志)"""
# 1. 获取上传文件
uploaded_file = request.FILES.get('file')
if not uploaded_file:
return Response(
{'error': '未提供文件'},
status=status.HTTP_400_BAD_REQUEST
)
# 2. 验证文件类型
valid_extensions = ['.xlsx', '.xls', '.csv']
file_extension = os.path.splitext(uploaded_file.name)[1].lower()
if file_extension not in valid_extensions:
return Response(
{'error': '不支持的文件类型'},
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
)
# 3. 读取文件内容
try:
if file_extension == '.csv':
df = pd.read_csv(uploaded_file)
else:
df = pd.read_excel(uploaded_file)
except Exception as e:
return Response(
{'error': f'文件解析失败: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
# 4. 验证数据格式
required_columns = ['name', 'email', 'department']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
return Response(
{'error': f'缺少必要列: {", ".join(missing_columns)}'},
status=status.HTTP_400_BAD_REQUEST
)
# 5. 处理数据(企业级逻辑)
success_count = 0
error_rows = []
for index, row in df.iterrows():
try:
# 数据验证
if not validate_user_data(row): # 需实现validate_user_data方法
raise ValueError('数据验证失败')
# 创建或更新用户
user, created = User.objects.update_or_create(
email=row['email'],
defaults={
'name': row['name'],
'department': row['department'],
# 其他字段...
}
)
success_count += 1
except Exception as e:
error_rows.append({
'row': index + 2, # 行号(含标题行)
'error': str(e),
'data': row.to_dict()
})
# 6. 创建导入日志
import_log = DataImportLog.objects.create(
user=request.user,
filename=uploaded_file.name,
total_rows=len(df),
success_count=success_count,
error_count=len(error_rows),
status='completed' if not error_rows else 'partial'
)
# 7. 返回结果
return Response({
'message': f'成功导入 {success_count} 条记录',
'total': len(df),
'success': success_count,
'errors': error_rows,
'log_id': import_log.id
}, status=status.HTTP_200_OK)
2.2 前端完整实现(Quasar + Vue3 + TypeScript)
<template>
<div class="q-pa-lg">
<!-- 上传区域 -->
<div class="q-mb-md">
<div class="text-h6">用户数据导入</div>
<q-separator class="q-my-md" />
<q-uploader
ref="uploader"
:url="uploadUrl"
label="拖放文件或点击上传"
multiple
batch
accept=".xlsx,.xls,.csv"
:headers="uploadHeaders"
:form-fields="formFields"
:auto-upload="false"
@added="onFilesAdded"
@removed="onFileRemoved"
@uploaded="onUploaded"
@failed="onUploadFailed"
style="max-width: 600px"
>
<template v-slot:header="scope">
<div class="row no-wrap items-center q-pa-sm q-gutter-xs bg-blue-1">
<q-btn
v-if="scope.canAddFiles"
icon="add"
round
dense
flat
color="primary"
>
<q-tooltip>添加文件</q-tooltip>
</q-btn>
<q-spinner
v-if="scope.isUploading"
color="primary"
size="24px"
/>
<div class="col">
<div class="text-weight-bold">用户数据导入</div>
<div>
{{ scope.uploadSizeLabel }} / {{ scope.uploadProgressLabel }}
</div>
</div>
<q-btn
v-if="scope.canUpload && !scope.isUploading"
icon="cloud_upload"
@click="scope.upload"
round
dense
flat
color="positive"
>
<q-tooltip>开始上传</q-tooltip>
</q-btn>
<q-btn
v-if="scope.isUploading"
icon="cancel"
@click="scope.abort"
round
dense
flat
color="negative"
>
<q-tooltip>取消上传</q-tooltip>
</q-btn>
</div>
</template>
<template v-slot:list="scope">
<q-list separator>
<q-item v-for="file in scope.files" :key="file.name">
<q-item-section>
<q-item-label class="text-weight-medium">
{{ file.name }}
</q-item-label>
<q-item-label caption>
大小: {{ formatFileSize(file.size) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-spinner
v-if="file.status === 'uploading'"
color="primary"
size="24px"
/>
<q-icon
v-else-if="file.status === 'failed'"
name="error"
color="negative"
/>
<q-icon
v-else-if="file.status === 'uploaded'"
name="check_circle"
color="positive"
/>
</q-item-section>
<q-item-section side>
<q-btn
icon="delete"
round
dense
flat
color="negative"
@click="scope.removeFile(file)"
/>
</q-item-section>
</q-item>
</q-list>
</template>
</q-uploader>
</div>
<!-- 上传结果展示 -->
<div v-if="importResult" class="q-mt-xl">
<div class="text-h6">导入结果</div>
<q-separator class="q-my-md" />
<q-card>
<q-card-section>
<div class="text-h6">
导入完成: {{ importResult.success }} / {{ importResult.total }} 条记录
</div>
<q-linear-progress
:value="importResult.success / importResult.total"
color="positive"
class="q-mt-sm"
/>
<!-- 错误记录表格 -->
<div v-if="importResult.errors.length" class="q-mt-lg">
<div class="text-negative text-weight-medium">
错误记录 ({{ importResult.errors.length }}):
</div>
<q-table
:rows="importResult.errors"
:columns="errorColumns"
row-key="row"
dense
flat
class="q-mt-sm"
>
<template v-slot:body-cell-data="props">
<q-td :props="props">
<pre>{{ JSON.stringify(props.value, null, 2) }}</pre>
</q-td>
</template>
</q-table>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="下载错误报告"
color="negative"
icon="download"
@click="downloadErrorReport"
:disable="!importResult.errors.length"
/>
<q-btn
label="查看导入日志"
color="info"
icon="description"
@click="viewImportLog"
/>
</q-card-actions>
</q-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
import { apiClient } from 'src/services/axios'
import { useAuthStore } from 'stores/auth'
const $q = useQuasar()
const authStore = useAuthStore()
const uploader = ref<any>(null)
const importResult = ref<any>(null)
// 上传配置
const uploadUrl = `${import.meta.env.VITE_API_BASE_URL}/users/import/`
const uploadHeaders = computed(() => [
{ name: 'Authorization', value: `Bearer ${authStore.token}` }
])
const formFields = [{ name: 'category', value: 'user-data' }]
// 错误表格列定义
const errorColumns = [
{ name: 'row', label: '行号', field: 'row', align: 'left' },
{ name: 'error', label: '错误信息', field: 'error', align: 'left' },
{ name: 'data', label: '数据', field: 'data', align: 'left' }
]
// 文件添加事件
const onFilesAdded = (files: File[]) => {
importResult.value = null // 重置结果
const maxSize = 10 * 1024 * 1024 // 10MB限制
files.forEach(file => {
if (file.size > maxSize) {
$q.notify({
type: 'negative',
message: `文件 "${file.name}" 超过大小限制 (10MB)`,
position: 'top-right'
})
uploader.value?.removeFile(file)
}
})
}
// 文件移除事件
const onFileRemoved = (files: File[]) => {
console.log('文件已移除:', files.map(f => f.name))
}
// 上传成功处理
const onUploaded = (info: any) => {
try {
const response = JSON.parse(info.xhr.response)
importResult.value = response
$q.notify({
type: 'positive',
message: `成功导入 ${response.success} 条记录`,
position: 'top-right',
timeout: 3000
})
} catch (error) {
console.error('解析响应失败:', error)
$q.notify({ type: 'negative', message: '解析导入结果失败' })
}
}
// 上传失败处理
const onUploadFailed = (info: any) => {
const file = info.files[0]
const error = info.xhr?.responseText || '未知错误'
$q.notify({
type: 'negative',
message: `文件上传失败: ${file?.name || '未知文件'}`,
caption: error.length > 100 ? error.substring(0, 100) + '...' : error,
position: 'top-right',
timeout: 5000
})
}
// 辅助方法:格式化文件大小
const formatFileSize = (bytes: number) => {
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 downloadErrorReport = () => {
if (!importResult.value?.errors.length) return
// 生成CSV内容
let csvContent = '行号,错误信息,数据\n'
importResult.value.errors.forEach((error: any) => {
csvContent += `${error.row},"${error.error.replace(/"/g, '""')}","${JSON.stringify(error.data).replace(/"/g, '""')}"\n`
})
// 创建并下载文件
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `导入错误报告_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
// 查看导入日志
const viewImportLog = () => {
if (importResult.value?.log_id) {
console.log('查看导入日志:', importResult.value.log_id)
$q.notify({ message: '已打开导入日志', color: 'info' })
}
}
</script>
<style scoped>
.q-uploader__header {
border-bottom: 1px solid #bbdefb;
}
.q-item {
border-bottom: 1px solid #eeeeee;
}
.q-linear-progress {
height: 10px;
border-radius: 5px;
}
</style>
三、企业级上传最佳实践
3.1 分块上传实现(大文件解决方案)
// 前端分块上传核心逻辑
const chunkedUpload = async (file, chunkSize = 5 * 1024 * 1024) => {
const totalChunks = Math.ceil(file.size / chunkSize