eagleye

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

 

posted on 2025-08-06 09:09  GoGrid  阅读(18)  评论(0)    收藏  举报

导航